Watch
1
0
Fork
You've already forked souveraine
0

fix: speech prefetch flag, agent identity on restore, workspace selector

Speech: prefetch() now sets synthProc._prefetch=true before voiceLookup
so the background synth doesn't auto-play. speak() skips voiceLookup
when the endpoint+voice are already resolved (faster repeat taps).

Ai: addMessage defaults model to Souveraine.currentAgentId so restored
messages (/resume, new conversations) show the agent icon/name instead
of a generic placeholder.

Session: create_with_messages_and_timestamps preserves persisted
created_at/updated_at through load_persisted so /resume sorts by
actual activity time, not server restart time.

TTS: ttsClean strips unclosed think blocks, fixes regex dotAll flag
for older Qt/V8 (phone), adds pronunciation map.

SubconsciousTicker: tap psychology icon to expand inline mini-log,
long-press to open the event panel overlay.

New: workspace-selector.sh + .desktop — wofi dmenu grid for quick
workspace 1-10 jump from the app drawer. Installed to phone via
deploy.sh symlink.
This commit is contained in:
Fimeg 2026-07-23 16:47:00 -04:00
commit 0b13e2d347
9 changed files with 442 additions and 72 deletions

View file

@ -238,12 +238,14 @@ impl Backend for LocalBackend {
let agents = self.server.agents.list(None).await?;
for agent in agents {
if let Some(store) = self.server.sessions.conversation_store_for(&agent.id) {
if let Ok(Some(_record)) = store.load_metadata(conversation_id).await {
if let Ok(Some(record)) = store.load_metadata(conversation_id).await {
let messages = store.load_messages(conversation_id).await?;
self.server.sessions.create_with_messages(
self.server.sessions.create_with_messages_and_timestamps(
&agent.id,
conversation_id.to_string(),
messages.clone(),
record.created_at,
record.updated_at,
);
return Ok(messages);
}

View file

@ -101,6 +101,20 @@ impl SessionManager {
agent_id: &str,
conversation_id: String,
messages: Vec<ConversationMessage>,
) -> String {
self.create_with_messages_and_timestamps(agent_id, conversation_id, messages, Utc::now(), Utc::now())
}
/// Like `create_with_messages` but preserves the persisted timestamps so
/// the sort order in `list_conversations` reflects actual last activity,
/// not the load time.
pub fn create_with_messages_and_timestamps(
&self,
agent_id: &str,
conversation_id: String,
messages: Vec<ConversationMessage>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> String {
let (sender, _receiver) = broadcast::channel(100);
let turn_count = messages
@ -112,8 +126,8 @@ impl SessionManager {
conversation_id: conversation_id.clone(),
agent_id: agent_id.to_string(),
messages,
created_at: Utc::now(),
updated_at: Utc::now(),
created_at,
updated_at,
turn_count,
last_n25: Utc::now(),
context_pressure: 0.0,
@ -217,7 +231,13 @@ impl SessionManager {
continue;
}
let messages = store.load_messages(&record.id).await.unwrap_or_default();
self.create_with_messages(agent_id, record.id.clone(), messages);
self.create_with_messages_and_timestamps(
agent_id,
record.id.clone(),
messages,
record.created_at,
record.updated_at,
);
}
Ok(records)

View file

@ -231,8 +231,8 @@ if [[ "${1:-}" == "--phone" ]]; then
printf '%s\n' "$deployed_revision" \
| "${ssh_i[@]}" "$PHONE_USB" "cat > ~/$PHONE_DEST/DEPLOYED-REVISION"
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
# Phone-only: install the mobile settings launcher into the app grid.
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop"
# Phone-only: install mobile launchers into the app grid.
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop && ln -sf ~/$PHONE_DEST/ii-base/modules/common/workspace-selector.desktop ~/.local/share/applications/workspace-selector.desktop"
finish_phone_maintenance
trap - EXIT
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."

View file

@ -157,31 +157,81 @@ Singleton {
return str.replace(/\\/g, '\\\\');
}
// Pronunciation fixes for VibeVoice — word-boundary replacements so the
// model says names and acronyms correctly. Ported from lettabot's Matrix
// TTS (src/channels/matrix/tts.ts), tuned for this voice over time.
readonly property var _ttsPronunciation: ({
"Xzaviar": "X-zay-V-ar", "xzaviar": "X-zay-V-ar",
"Jean Luc": "Zhan-Look", "jean luc": "Zhan-Look",
"Sebastian": "Se-BASS-chen", "sebastian": "Se-BASS-chen",
"API": "A P I", "SDK": "S D K",
"E2EE": "end-to-end encrypted",
"TTS": "text to speech", "STT": "speech to text",
})
function _applyTtsPronunciation(text) {
let result = text;
for (const wrong in root._ttsPronunciation) {
const right = root._ttsPronunciation[wrong];
result = result.replace(new RegExp(`\\b${wrong.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi"), right);
}
return result;
}
/**
* Cleans assistant reply text for text-to-speech: strips <think> blocks,
* code fences, and markdown formatting so only the spoken voice remains.
* agent control tags, HTML, code fences, markdown, and most emoji, and
* applies pronunciation fixes — so only the spoken voice remains.
* Returns the cleaned text (empty string if nothing speakable survives).
* Order (ported from lettabot's cleanTextForTTS, plus Souveraine think
* blocks): control tags → color/spoiler → HTML → think → code/markdown →
* emoji → pronunciation → whitespace.
* @param { string } text
* @returns { string }
*/
function ttsClean(text) {
if (!text)
return "";
return text
// Remove <think>...</think> blocks entirely
.replace(/<think>[\s\S]*?<\/think>/g, "")
// Remove markdown headers, bold, italic, code fences
.replace(/```[\s\S]*?```/g, " ")
.replace(/#{1,6}\s/g, " ")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
// Remove markdown links and images
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1")
// Collapse whitespace
.replace(/\n{3,}/g, "\n\n")
.trim();
let cleaned = text;
// Strip agent control tags
cleaned = cleaned.replace(/\[silent\]/gi, "");
cleaned = cleaned.replace(/\[chromatophore\]/gi, "");
cleaned = cleaned.replace(/\[!c\]/gi, "");
cleaned = cleaned.replace(/\[!s\]/gi, "");
cleaned = cleaned.replace(/\[react:[^\]]*\]/gi, "");
// Strip color syntax {color|text} → keep text; spoilers ||text|| → keep text
cleaned = cleaned.replace(/\{[^}|]+\|([^}]+)\}/g, "$1");
// dotAll via [\s\S] (not the 's' flag — older Qt/V8 rejects it)
cleaned = cleaned.replace(/\|\|([\s\S]+?)\|\|/g, "$1");
// Remove <think>...</think> blocks entirely (Souveraine reasoning).
// MUST run before the HTML-tag strip, or the tags get eaten first and
// the reasoning survives as plain text. Also drop an unclosed <think>
// (stream cut mid-reasoning) so we never speak a half-thought — by
// default we want only the actual message, not the thinking.
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/g, " ");
cleaned = cleaned.replace(/<think>[\s\S]*$/g, " ");
// Strip HTML tags (keep content)
cleaned = cleaned.replace(/<[^>]+>/g, "");
// Remove markdown headers, bold, italic, code fences
cleaned = cleaned.replace(/```[\s\S]*?```/g, " ");
cleaned = cleaned.replace(/#{1,6}\s/g, " ");
cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, "$1");
cleaned = cleaned.replace(/\*([^*]+)\*/g, "$1");
cleaned = cleaned.replace(/`([^`]+)`/g, "$1");
// Remove markdown links and images
cleaned = cleaned.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1");
cleaned = cleaned.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1");
// Emoji: preserve ✨ and 🎤 (the model says those fine), strip the rest.
// Marker-swap so the preserved ones survive the broad Unicode strip.
const sparkle = cleaned.includes("✨"), mic = cleaned.includes("🎤");
cleaned = cleaned.replace(/✨/g, "SPK").replace(/🎤/g, "MIC");
cleaned = cleaned.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2702}-\u{27B0}\u{24C2}-\u{1F251}\u{1F900}-\u{1FAFF}]/gu, "");
cleaned = cleaned.replace(/SPK/g, sparkle ? "✨" : "").replace(/MIC/g, mic ? "🎤" : "");
// Pronunciation fixes
cleaned = root._applyTtsPronunciation(cleaned);
// Collapse whitespace
cleaned = cleaned.replace(/\s+/g, " ").trim();
return cleaned;
}
/**

View file

@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Workspace
Comment=Jump to a numbered workspace
Exec=/home/ani/.config/quickshell/souveraine/modules/common/workspace-selector.sh
Icon=view-grid-symbolic
Terminal=false
Categories=Utility;

View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Workspace Selector — quick jump to a numbered workspace
# Uses wofi for touch-friendly selection
set -euo pipefail
# Generate list of workspaces 1-10 (pipe to wofi dmenu mode)
CHOICE=$(printf '%s\n' {1..10} | wofi --dmenu --prompt="Go to workspace" --width=200 --height=400 --columns=2)
# If user cancelled, exit
[[ -z "$CHOICE" ]] && exit 0
# Strip any non-numeric prefix wofi might add (e.g. "1 - ")
CHOICE="${CHOICE##* }"
CHOICE="${CHOICE//[^0-9]/}"
# Validate and dispatch
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= 10 )); then
hyprctl dispatch "workspace $CHOICE"
fi

View file

@ -5,22 +5,30 @@ import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell
// Tier 1 of the subconscious three-tier visibility design (see
// docs/tasks/subconscious-surfacing-threshold.md): a transient, fading line
// that shows the subconscious's live reasoning/tools while its N+1 pass runs.
// Visible only while Ai.subconsciousActive; fed by Ai.subconsciousStream;
// cleared (and snapshotted to the Tier-2 log) when the pass ends.
//
// Interaction: the psychology icon is a real affordance, not decoration.
// • tap → expand/collapse an inline mini-log (last few entries) in place
// • long-press → open the Tier-2 Subconscious event panel as a draggable
// overlay pane (and pin it), so the live stream and the persistent log
// are one gesture apart.
Rectangle {
id: root
Layout.fillWidth: true
implicitHeight: row.implicitHeight + 2 * root.padding
implicitHeight: col.implicitHeight + 2 * root.padding
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
visible: Ai.subconsciousActive && Ai.subconsciousStream.length > 0
opacity: visible ? 1 : 0
property real padding: 6
property bool expanded: false
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
@ -35,48 +43,136 @@ Rectangle {
Timer {
interval: 4000
repeat: true
running: root.visible
running: root.visible && !root.expanded
onTriggered: {
if (Ai.subconsciousStream.length > 1)
Ai.subconsciousStream = Ai.subconsciousStream.slice(1);
}
}
RowLayout {
id: row
function _openPanel() {
// Open the Tier-2 Subconscious event panel as a draggable overlay
// pane and pin it, so it stays after the overlay closes. Mirrors what
// OverlayTaskbar's WidgetButton does on click.
const id = "subconsciousEventPanel";
const open = Persistent.states.overlay.open;
if (!open.includes(id)) {
Persistent.states.overlay.open.push(id);
}
OverlayContext.pin(id, true);
GlobalStates.overlayOpen = true;
}
ColumnLayout {
id: col
anchors.fill: parent
anchors.margins: root.padding
spacing: root.padding
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
iconSize: Appearance.font.pixelSize.larger
text: "psychology"
color: Appearance.colors.colSubtext
NumberAnimation on opacity {
// gentle pulse while the pass runs
from: 0.5; to: 1; duration: 1200
running: root.visible; loops: Animation.Infinite
easing.type: Easing.InOutSine
RowLayout {
Layout.fillWidth: true
spacing: root.padding
// The icon is the affordance: tap = expand inline, long-press =
// open the overlay panel. Press-and-hold beats a separate button
// — keeps the ticker visually quiet.
Item {
Layout.alignment: Qt.AlignVCenter
implicitWidth: icon.implicitWidth
implicitHeight: icon.implicitHeight
MaterialSymbol {
id: icon
anchors.centerIn: parent
iconSize: Appearance.font.pixelSize.larger
text: "psychology"
color: Appearance.colors.colSubtext
NumberAnimation on opacity {
// gentle pulse while the pass runs
from: 0.5; to: 1; duration: 1200
running: root.visible; loops: Animation.Infinite
easing.type: Easing.InOutSine
}
}
MouseArea {
anchors.fill: parent
onClicked: root.expanded = !root.expanded
onPressAndHold: root._openPanel()
cursorShape: Qt.PointingHandCursor
}
}
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: {
const s = Ai.subconsciousStream;
if (s.length === 0) return "";
const last = s[s.length - 1];
const isTool = last.kind === "tool_call" || last.kind === "tool_result";
const tag = isTool ? Translation.tr("tool") : Translation.tr("thinking");
// For a forming thought (tokens), show the tail of the sentence
// so the newest words are always visible as it grows, rather
// than the head eliding away. Tools are short — show in full.
const body = (!isTool && last.text.length > 120)
? "…" + last.text.slice(-120) : last.text;
return `${tag} · ${body}`;
}
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
// Hint that there's more — expand/collapse chevron, and a long-
// press affordance tooltip.
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
iconSize: Appearance.font.pixelSize.small
text: root.expanded ? "expand_less" : "expand_more"
color: Appearance.colors.colSubtext
visible: Ai.subconsciousStream.length > 0
MouseArea {
anchors.fill: parent
onClicked: root.expanded = !root.expanded
onPressAndHold: root._openPanel()
cursorShape: Qt.PointingHandCursor
}
StyledToolTip {
text: Translation.tr("Tap to expand · Hold to open the event panel")
}
}
}
StyledText {
// Inline mini-log: the last several entries (newest at the bottom),
// shown when expanded. Read-only — the full searchable log lives in
// the Tier-2 overlay panel.
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: {
const s = Ai.subconsciousStream;
if (s.length === 0) return "";
const last = s[s.length - 1];
const tag = last.kind === "tool_call" || last.kind === "tool_result"
? Translation.tr("tool") : Translation.tr("thinking");
return `${tag} · ${last.text}`;
visible: root.expanded && Ai.subconsciousStream.length > 0
spacing: 2
Repeater {
// Show up to the last 6 entries, oldest first so the forming
// thought reads top-to-bottom.
model: ScriptModel {
values: Ai.subconsciousStream.slice(-6)
}
delegate: StyledText {
required property var modelData
Layout.fillWidth: true
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colSubtext
text: {
const isTool = modelData.kind === "tool_call" || modelData.kind === "tool_result";
const tag = isTool ? Translation.tr("tool") : Translation.tr("thinking");
return `${tag} · ${modelData.text}`;
}
}
}
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
}

View file

@ -242,10 +242,32 @@ Singleton {
}
// Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result.
// Consecutive tokens are coalesced onto the current line so the ticker reads
// as a thought forming (a sentence), not a single flickering word replaced
// on every token. A tool_call/tool_result starts a fresh line — those are
// natural thought boundaries.
function pushSubconsciousStream(kind, text) {
const t = String(text ?? "").trim();
if (t.length === 0) return;
root.subconsciousStream = [...root.subconsciousStream, { kind, text: t }];
const stream = root.subconsciousStream.slice();
const last = stream.length > 0 ? stream[stream.length - 1] : null;
if (kind === "token" && last && last.kind === "token") {
// Same thought — append to the forming sentence, capped so a very
// long monologue doesn't grow unbounded in the live view (the full
// text is still snapshotted to the Tier-2 log on pass end).
const merged = (last.text + " " + t);
stream[stream.length - 1] = {
kind: "token",
text: merged.length > 280 ? merged.slice(-280) : merged,
};
} else {
stream.push({ kind, text: t });
}
// Keep the live buffer shallow: the ticker only shows the tail, and a
// long pass shouldn't accumulate hundreds of entries in memory. The
// Tier-2 snapshot joins all entries anyway.
if (stream.length > 12) stream.shift();
root.subconsciousStream = stream;
}
// Promote the Tier-1 stream into the Tier-2 log as one event, then clear
@ -286,6 +308,14 @@ Singleton {
if (GlobalStates.screenLocked && root.streamingMessage.content.length > 0) {
root._redactMessage(root.streamingMessage);
}
// Prefetch the audio for this reply so Speak is near-instant if the
// user taps it. Background fill only — never auto-plays. Only the
// latest reply is cached; a subsequent reply overwrites it. If the
// synth fails it is not retried (manual Speak handles that).
if (Speech.enabled) {
const spoken = StringUtils.ttsClean(root.streamingMessage.content ?? "");
if (spoken.length > 0) Speech.prefetch(spoken);
}
if (root.postResponseHook) {
root.postResponseHook();
root.postResponseHook = null;
@ -408,6 +438,7 @@ Singleton {
if (message.length === 0) return;
const aiMessage = aiMessageComponent.createObject(root, {
"role": role,
"model": Souveraine.currentAgentId,
"content": message,
"rawContent": message,
"thinking": false,

View file

@ -19,6 +19,14 @@ import QtQuick
* Config.options.speech.tts.enable is the user's kill switch (settings →
* Speech); Config.options.speech.tts.endpoint, when set, overrides the
* server-mapped tts_url.
*
* Prefetch: the latest assistant reply is synthesized into the single tmpfs
* file in the background the moment it finishes streaming, so tapping Speak
* is near-instant when the audio is already ready. Prefetch NEVER auto-plays
* — the user always initiates playback. A prefetch that fails is not retried
* (the manual Speak path retries on its own when tapped). Only the latest
* reply is kept; a new/edited reply invalidates the cached file because
* readiness is keyed on the exact text.
*/
Singleton {
id: root
@ -28,23 +36,119 @@ Singleton {
property string lastError: ""
property string _pendingText: ""
// Resolved endpoint + voice from the last /v1/config lookup, kept so a
// transient synth failure can be retried without re-resolving.
property string _synthUrl: ""
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
// failed request) or time out on a slow synth. One retry recovers the common
// case; a second failure surfaces a human message.
property int _synthAttempts: 0
readonly property int _synthMaxAttempts: 2
readonly property string _outFile: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/souveraine-speech.mp3"
// What the file on disk currently holds (the exact text it was synthesized
// from), or "" if nothing valid is cached. Keyed on text so a new or edited
// reply naturally misses → Speak synthesizes fresh. Set only on a successful
// synth; cleared by stop()/speak()/prefetch() of different text.
property string _readyText: ""
// speak — synthesize and play `text` in the agent's voice. A new call
// replaces any in-flight synthesis or playback.
// replaces any in-flight synthesis or playback. If `text` is already
// cached on disk (from a prefetch), skip synthesis and play instantly.
// Check the cache BEFORE stopping — a prefetch that just completed should
// be honored, not killed and re-requested.
function speak(text) {
const t = String(text ?? "").trim();
if (t.length === 0 || !root.enabled) return;
stop();
root.lastError = "";
// Prefetch hit: audio for exactly this text is already on disk — play
// it straight away, no synth round-trip. Don't call stop() here;
// if a prefetch is still running for a *different* message, stop()
// would kill it — but our text matches, so we know the file is ours.
if (root._readyText === t) {
stop();
root._pendingText = t;
root._play();
return;
}
// No cache hit — stop anything in-flight and synthesize fresh.
stop();
root._pendingText = t;
voiceLookup.running = true;
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) {
root._fireSynth(false /*interactive*/);
} else {
voiceLookup.running = true;
}
}
// prefetch — synthesize `text` into the cache file in the background so a
// later speak() of the same text plays instantly. Does NOT play. A prefetch
// failure is silent and not retried; the eventual manual speak() will
// synthesize (with its own retry) if the text still isn't ready.
function prefetch(text) {
const t = String(text ?? "").trim();
if (t.length === 0 || !root.enabled) return;
// Already have exactly this text ready — don't re-request it.
if (root._readyText === t) return;
// Don't let a prefetch clobber a speak() the user just initiated.
if (root.speaking && root._pendingText !== t) return;
root._pendingText = t;
root._synthAttempts = 0;
// 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) {
root._fireSynth(true /*prefetch*/);
} else {
voiceLookup.running = true;
}
}
function stop() {
voiceLookup.running = false;
synthProc.running = false;
playProc.running = false;
retryTimer.running = false;
}
// Play whatever is in the cache file.
function _play() {
playProc.running = true;
}
// Fire (or re-fire) the synth request with the already-resolved url+voice.
// `prefetch` marks the result as a background fill (no playback, no retry,
// populate _readyText on success) vs. an interactive speak().
function _fireSynth(prefetch) {
synthProc._prefetch = prefetch;
synthProc.command = [
"curl", "-sf", "--max-time", "180",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", JSON.stringify({
input: root._pendingText,
voice: root._synthVoice,
model: "vibevoice-v1"
}),
"-o", root._outFile,
`${root._synthUrl.replace(/\/$/, "")}/audio/speech`
];
synthProc.running = true;
}
// Delay before a retry — short, just long enough for the server to clear.
Timer {
id: retryTimer
interval: 750
repeat: false
onTriggered: root._fireSynth(false /*interactive speak retries only*/)
}
// ── who → voice, from souveraine ─────────────────────────────────────
@ -68,22 +172,17 @@ Singleton {
console.log("[Speech]", root.lastError);
return;
}
synthProc.command = [
"curl", "-sf", "--max-time", "60",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", JSON.stringify({
input: root._pendingText,
voice: voice,
model: "vibevoice-v1"
}),
"-o", root._outFile,
`${ttsUrl.replace(/\/$/, "")}/audio/speech`
];
synthProc.running = true;
root._synthUrl = ttsUrl;
root._synthVoice = voice;
// 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.
root._fireSynth(synthProc._prefetch ?? false);
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
if (exitCode !== 0 && exitCode !== 15) {
root.lastError = "souveraine server unreachable for voice mapping";
console.log("[Speech]", root.lastError);
}
@ -93,13 +192,56 @@ Singleton {
// ── synthesis ────────────────────────────────────────────────────────
Process {
id: synthProc
// True when this synth is a background prefetch (no playback, no retry).
property bool _prefetch: false
onExited: (exitCode) => {
if (exitCode === 0) {
playProc.running = true;
} else {
root.lastError = `TTS synthesis failed (curl exit ${exitCode})`;
console.log("[Speech]", root.lastError);
// Success: the file now holds _pendingText.
root._readyText = root._pendingText;
if (synthProc._prefetch) {
// Prefetch only fills the cache; never auto-plays.
synthProc._prefetch = false;
return;
}
root._play();
return;
}
// Prefetch failures are silent and not retried — per design, we
// don't burn a second request on background fill. The manual
// speak() path will synthesize (and retry) when actually tapped.
if (synthProc._prefetch) {
synthProc._prefetch = false;
console.log("[Speech] prefetch failed (curl exit " + exitCode + ") — will synth on demand when spoken");
return;
}
// Exit 15 = SIGTERM — stop() killed this synth (user tapped stop,
// or a new speak() replaced it). This is always intentional, never
// an error. Suppress silently.
if (exitCode === 15) {
return;
}
// Transient failures: exit 22 = HTTP ≥400 (the server's intermittent
// 500 under model concurrency), exit 28 = timeout (slow synth).
// Retry once; a collision or a slow first attempt usually clears.
const transient = (exitCode === 22 || exitCode === 28);
root._synthAttempts += 1;
if (transient && root._synthAttempts < root._synthMaxAttempts) {
console.log(`[Speech] synth exit ${exitCode}, retry ${root._synthAttempts}/${root._synthMaxAttempts - 1}`);
retryTimer.running = true;
return;
}
if (exitCode === 28) {
root.lastError = root._synthAttempts > 1
? "voice synthesis timed out twice — the server may be busy or the reply too long"
: "voice synthesis timed out";
} else if (exitCode === 22) {
root.lastError = root._synthAttempts > 1
? "voice synthesis failed twice — the TTS server returned an error (may be busy)"
: "voice synthesis failed (server error)";
} else {
root.lastError = `voice synthesis failed (curl exit ${exitCode})`;
}
console.log("[Speech]", root.lastError);
}
}
@ -110,7 +252,8 @@ Singleton {
`mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` +
`|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`]
onExited: (exitCode) => {
if (exitCode !== 0) {
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
if (exitCode !== 0 && exitCode !== 15) {
root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`;
console.log("[Speech]", root.lastError);
}