From 93fe30a8e71dbd5baff6278d93c2be585cac7db0 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Wed, 12 Aug 2026 14:50:57 -0400 Subject: [PATCH] shell: land the owned message delegate and the speech fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied 0009 then 0008 (0008's re-synthesize control calls a function 0009 adds). Casey applied and reloaded; verified against a running shell rather than from a clean load. 0009 Speech.qml stop() could not stop. playProc ran `sh -c "mpv ... || ffplay ..."` — a compound command, so sh does not exec-replace itself and SIGTERM killed the wrapper while the player kept sounding as an orphan. Reproduced directly. That is the back-to-back TTS slam: every stop left audio playing and the next speak started a second player over it. Shell dropped; the pid held is now the pid making noise. ffplay fallback deleted rather than repaired — needing a fallback is what forced the wrapper that broke the kill. resynthesize() added, bypassing the cache: the button existed for "that came out wrong" and, being keyed on the same text, always replayed the identical file. synthesizing/playing split out of one `speaking` boolean. 0008 AiChat.qml One line: delegate AiMessage -> AgentMessage. The vendor block named 8 tools; the registry holds 19. The 11 it could not see were exactly the interiority surface — outfit, nickname, subagent, atmosphere, reach, consult, itinerary, todo, schedule, halt, intrusive. regenerate and edit dropped deliberately, per Casey: text regeneration is not possible (Ai.regenerate() already returned advice) and there is no in-place edit (the vendor wrote to a local array the server never sees). delete is armed and states that it hides locally only. Do not restore them. Queue entries removed here, per the directory's own discipline. --- .../modules/ii/sidebarLeft/AiChat.qml | 3 +- ...agent-surface-owned-message-delegate.patch | 41 ------ .../0009-speech-stop-and-resynthesize.patch | 136 ------------------ surfaces/quickshell/services/Speech.qml | 64 ++++++++- 4 files changed, 62 insertions(+), 182 deletions(-) delete mode 100644 surfaces/quickshell/patches/0008-agent-surface-owned-message-delegate.patch delete mode 100644 surfaces/quickshell/patches/0009-speech-stop-and-resynthesize.patch diff --git a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml index bba12a1..0d6e04e 100644 --- a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml +++ b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml @@ -6,6 +6,7 @@ import qs.modules.common.functions import qs.modules.souveraine.subconscious import qs.modules.ii.sidebarLeft.aiChat import qs.modules.souveraine.island +import qs.modules.souveraine.agent import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -446,7 +447,7 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) return message?.visibleToUser ?? true; }) } - delegate: AiMessage { + delegate: AgentMessage { required property var modelData required property int index messageIndex: index diff --git a/surfaces/quickshell/patches/0008-agent-surface-owned-message-delegate.patch b/surfaces/quickshell/patches/0008-agent-surface-owned-message-delegate.patch deleted file mode 100644 index 03827e6..0000000 --- a/surfaces/quickshell/patches/0008-agent-surface-owned-message-delegate.patch +++ /dev/null @@ -1,41 +0,0 @@ -From f76c911a2f37352b0846b2ec70a96126ac275ee2 Mon Sep 17 00:00:00 2001 -From: Souveraine -Date: Wed, 12 Aug 2026 13:09:27 -0400 -Subject: [PATCH] shell: render the agent surface through the owned message - delegate - -The sidebar's message list drew through ii-base's AiMessage, so every tool -call was rendered by the vendor snapshot's ToolCallBlock, which knows 8 of -the registry's 19 tools. The 11 it cannot name are exactly the interiority -verbs. TASK-72 step 2. - -Requires modules/souveraine/agent/ composed (deploy.sh MANIFEST, already in -tree). One line reverts it. ---- - surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml | 3 ++- - 1 file changed, 2 insertions(+), 1 deletion(-) - -diff --git a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml -index bba12a1..0d6e04e 100644 ---- a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml -+++ b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml -@@ -6,6 +6,7 @@ import qs.modules.common.functions - import qs.modules.souveraine.subconscious - import qs.modules.ii.sidebarLeft.aiChat - import qs.modules.souveraine.island -+import qs.modules.souveraine.agent - import QtQuick - import QtQuick.Controls - import QtQuick.Layouts -@@ -446,7 +447,7 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) - return message?.visibleToUser ?? true; - }) - } -- delegate: AiMessage { -+ delegate: AgentMessage { - required property var modelData - required property int index - messageIndex: index --- -2.55.0 - diff --git a/surfaces/quickshell/patches/0009-speech-stop-and-resynthesize.patch b/surfaces/quickshell/patches/0009-speech-stop-and-resynthesize.patch deleted file mode 100644 index 29bb121..0000000 --- a/surfaces/quickshell/patches/0009-speech-stop-and-resynthesize.patch +++ /dev/null @@ -1,136 +0,0 @@ -From bae112271e6a973bd9d178f68f232774751caf0e Mon Sep 17 00:00:00 2001 -From: Souveraine -Date: Wed, 12 Aug 2026 13:53:26 -0400 -Subject: [PATCH] shell: make stop actually stop, and re-synthesis actually - re-synthesize -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -Three defects in the speech path, all of which present to the user as the -same symptom — audio that piles up and cannot be called back. - -stop() could not reach the player. playProc ran ["sh", "-c", "mpv ... || -ffplay ..."], and a compound command means sh does not exec-replace itself: -it forks the player and waits. Setting running = false SIGTERMs sh, sh dies, -and the player keeps sounding as an orphan. Reproduced directly — the wrapper -died and the child survived. Fixed by invoking mpv with no shell at all, so -the pid quickshell holds is the pid making noise. The ffplay fallback is -dropped rather than repaired: its own invocation was wrong, and needing a -fallback is what forced the wrapper that broke the kill. - ---keep-open=no --idle=no added because mpv can finish a stream, print -(Paused) and never exit, which renders as speaking forever. - -resynthesize() added. speak() opens with a cache check keyed on the text, and -re-synthesis is by definition the same text, so the re-synthesize control was -guaranteed to hit cache and replay the identical broken audio. Invalidating -_readyText before delegating is the fix. - -synthesizing/playing split out of speaking. Synthesis of a short line measured -~11s against the service, and the shell showed the same state throughout as it -shows while actually talking — which is why a press feels unacknowledged and -gets pressed again. speaking is left untouched so nothing downstream shifts -meaning. ---- - surfaces/quickshell/services/Speech.qml | 64 +++++++++++++++++++++++-- - 1 file changed, 60 insertions(+), 4 deletions(-) - -diff --git a/surfaces/quickshell/services/Speech.qml b/surfaces/quickshell/services/Speech.qml -index 0655a5f..6e07eb8 100644 ---- a/surfaces/quickshell/services/Speech.qml -+++ b/surfaces/quickshell/services/Speech.qml -@@ -33,7 +33,22 @@ Singleton { - id: root - - readonly property bool enabled: Config.options?.speech?.tts?.enable ?? false -+ -+ // `speaking` is the OR that surfaces have always read — kept as-is so -+ // nothing downstream changes meaning under them. - property bool speaking: synthProc.running || playProc.running -+ -+ // But one boolean cannot distinguish "waiting on the synthesizer" from -+ // "audio is coming out of the speaker", and those look nothing alike to a -+ // person: synthesis of a short line measured ~11s against the service, -+ // and during all of it the shell showed the same state it shows while -+ // actually talking. That is the whole reason a press feels unacknowledged -+ // and gets pressed again. -+ // -+ // Split, so a surface can show a spinner for one and a level for the -+ // other, and so a stop button can say which thing it is about to stop. -+ readonly property bool synthesizing: synthProc.running -+ readonly property bool playing: playProc.running - property string lastError: "" - - property string _pendingText: "" -@@ -131,6 +146,24 @@ Singleton { - retryTimer.running = false; - } - -+ // resynthesize — request fresh audio for text we may already have cached. -+ // -+ // speak() opens with a cache check keyed on the text itself, and -+ // re-synthesis is by definition the *same text* — so calling speak() to -+ // "try again" is guaranteed to hit the cache and replay the identical -+ // broken audio. The one control that exists for "that came out wrong" -+ // could not do the only thing it is for. -+ // -+ // Invalidating _readyText before delegating is the whole fix: it forces -+ // speak() down the synthesis path rather than the playback path. -+ function resynthesize(text) { -+ const t = String(text ?? "").trim(); -+ if (t.length === 0 || !root.enabled) return; -+ stop(); -+ root._readyText = ""; -+ root.speak(t); -+ } -+ - // 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. -@@ -278,15 +311,38 @@ Singleton { - } - - // ── playback ───────────────────────────────────────────────────────── -+ // -+ // No `sh -c` wrapper, deliberately. The previous form was -+ // -+ // ["sh", "-c", "mpv ... || ffplay ..."] -+ // -+ // and a compound command means sh does NOT exec-replace itself: it forks -+ // the player as a child and waits. So `playProc.running = false` sends -+ // SIGTERM to *sh*, sh dies, and the player keeps making noise as an -+ // orphan. Reproduced directly: killing the wrapper left the child alive. -+ // -+ // That is why stop() never stopped anything, and why two speak() calls in -+ // a row played over each other instead of replacing one another. -+ // -+ // One player, invoked directly, so the pid quickshell holds is the pid -+ // making sound. The ffplay fallback is dropped rather than fixed: its -+ // invocation was already wrong (raw input needs -i) and a fallback is -+ // exactly what forced the shell wrapper that broke the kill. mpv is -+ // present on both the laptop and the phone; if it is ever missing, the -+ // honest outcome is a named error, not silent audio nobody can stop. -+ // -+ // --keep-open=no --idle=no is not cosmetic. mpv can reach the end of a -+ // stream, print (Paused), and never exit — which, since `speaking` is -+ // derived from playProc.running, renders as speaking forever. - Process { - id: playProc -- command: ["sh", "-c", -- `mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` + -- `|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`] -+ command: ["mpv", "--no-video", "--really-quiet", -+ "--keep-open=no", "--idle=no", root._outFile] - onExited: (exitCode) => { - // Exit 15 = SIGTERM from stop(); not a real failure — suppress. -+ // This now actually reaches mpv rather than a shell wrapper. - if (exitCode !== 0 && exitCode !== 15) { -- root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`; -+ root.lastError = `audio playback failed (exit ${exitCode}) — is mpv installed?`; - console.log("[Speech]", root.lastError); - } - } --- -2.55.0 - diff --git a/surfaces/quickshell/services/Speech.qml b/surfaces/quickshell/services/Speech.qml index 0655a5f..6e07eb8 100644 --- a/surfaces/quickshell/services/Speech.qml +++ b/surfaces/quickshell/services/Speech.qml @@ -33,7 +33,22 @@ Singleton { id: root readonly property bool enabled: Config.options?.speech?.tts?.enable ?? false + + // `speaking` is the OR that surfaces have always read — kept as-is so + // nothing downstream changes meaning under them. property bool speaking: synthProc.running || playProc.running + + // But one boolean cannot distinguish "waiting on the synthesizer" from + // "audio is coming out of the speaker", and those look nothing alike to a + // person: synthesis of a short line measured ~11s against the service, + // and during all of it the shell showed the same state it shows while + // actually talking. That is the whole reason a press feels unacknowledged + // and gets pressed again. + // + // Split, so a surface can show a spinner for one and a level for the + // other, and so a stop button can say which thing it is about to stop. + readonly property bool synthesizing: synthProc.running + readonly property bool playing: playProc.running property string lastError: "" property string _pendingText: "" @@ -131,6 +146,24 @@ Singleton { retryTimer.running = false; } + // resynthesize — request fresh audio for text we may already have cached. + // + // speak() opens with a cache check keyed on the text itself, and + // re-synthesis is by definition the *same text* — so calling speak() to + // "try again" is guaranteed to hit the cache and replay the identical + // broken audio. The one control that exists for "that came out wrong" + // could not do the only thing it is for. + // + // Invalidating _readyText before delegating is the whole fix: it forces + // speak() down the synthesis path rather than the playback path. + function resynthesize(text) { + const t = String(text ?? "").trim(); + if (t.length === 0 || !root.enabled) return; + stop(); + root._readyText = ""; + root.speak(t); + } + // 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. @@ -278,15 +311,38 @@ Singleton { } // ── playback ───────────────────────────────────────────────────────── + // + // No `sh -c` wrapper, deliberately. The previous form was + // + // ["sh", "-c", "mpv ... || ffplay ..."] + // + // and a compound command means sh does NOT exec-replace itself: it forks + // the player as a child and waits. So `playProc.running = false` sends + // SIGTERM to *sh*, sh dies, and the player keeps making noise as an + // orphan. Reproduced directly: killing the wrapper left the child alive. + // + // That is why stop() never stopped anything, and why two speak() calls in + // a row played over each other instead of replacing one another. + // + // One player, invoked directly, so the pid quickshell holds is the pid + // making sound. The ffplay fallback is dropped rather than fixed: its + // invocation was already wrong (raw input needs -i) and a fallback is + // exactly what forced the shell wrapper that broke the kill. mpv is + // present on both the laptop and the phone; if it is ever missing, the + // honest outcome is a named error, not silent audio nobody can stop. + // + // --keep-open=no --idle=no is not cosmetic. mpv can reach the end of a + // stream, print (Paused), and never exit — which, since `speaking` is + // derived from playProc.running, renders as speaking forever. Process { id: playProc - command: ["sh", "-c", - `mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` + - `|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`] + command: ["mpv", "--no-video", "--really-quiet", + "--keep-open=no", "--idle=no", root._outFile] onExited: (exitCode) => { // Exit 15 = SIGTERM from stop(); not a real failure — suppress. + // This now actually reaches mpv rather than a shell wrapper. if (exitCode !== 0 && exitCode !== 15) { - root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`; + root.lastError = `audio playback failed (exit ${exitCode}) — is mpv installed?`; console.log("[Speech]", root.lastError); } }