Watch
1
0
Fork
You've already forked souveraine
0

patches: queue 0009 speech stop/resynthesize; unblock 0008

0008 was blocked because the owned delegate carried none of the vendor's
seven message controls. df11bba carries five and drops two deliberately;
the header now records which and why rather than only lifting the warning.

0009 depends on nothing but is a prerequisite for 0008's re-synthesize
control doing anything real.
This commit is contained in:
Fimeg 2026-08-12 13:55:35 -04:00
commit ba13d56afc
2 changed files with 188 additions and 6 deletions

View file

@ -0,0 +1,136 @@
From bae112271e6a973bd9d178f68f232774751caf0e Mon Sep 17 00:00:00 2001
From: Souveraine <souveraine@wiuf.net>
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

View file

@ -1,12 +1,36 @@
# patches/
> **⚠ 0008 IS BLOCKED — DO NOT APPLY.**
> **⚠ 0008 — control-row parity resolved `df11bba`; still needs one load.**
> `0008-agent-surface-owned-message-delegate.patch` swaps the message delegate
> to `modules/souveraine/agent/AgentMessage.qml`, which does **not yet carry
> the vendor message control row**: Regenerate, Speak, Re-synthesize, Copy,
> Edit, Show-raw, Delete (`ii-base/.../aiChat/AiMessage.qml:196-308`).
> Applying it silently removes all seven. Audited 2026-08-12; unblock only
> once the row is carried over and the affordances are seen to work.
> to `modules/souveraine/agent/AgentMessage.qml`. When it was queued, that file
> carried **none** of the vendor's seven controls
> (`ii-base/.../aiChat/AiMessage.qml:196-308`), so applying it would have
> silently removed all seven — a parity regression wearing the costume of a
> swap.
>
> As of `df11bba` five are carried (Speak, Re-synthesize, Copy, Show-raw,
> Delete) and **two are dropped deliberately**, which is a decision and not an
> oversight:
>
> - **Regenerate** — the conversation is forward-only. `Ai.regenerate()` is
> already a no-op returning advice, so the button's only behaviour was to
> explain it did nothing.
> - **Edit** — there is no in-place edit. The vendor's wrote to a local array
> the server never sees, so the message read back was not the message held.
>
> **Delete is armed, not immediate.** `removeMessage()` splices two local
> arrays and leaves the server transcript untouched — `/resume` brings the
> message straight back — so it is a view filter wearing a delete icon. The
> armed row says so in words.
>
> Apply **0009 first or together**: the re-synthesize control prefers
> `Speech.resynthesize()`, which 0009 adds. Without it the control degrades to
> the legacy cache-hitting path rather than breaking, but that path cannot
> actually re-synthesize.
>
> Lint: 0 syntax findings; every other finding traces to one directory import
> qmllint cannot resolve, proven environmental because `AiChat.qml` — live in
> the running shell — produces the identical failure. **Nothing has loaded it.**
Staged shell changes that are **not** applied to the tree.
@ -56,6 +80,28 @@ think-fence collision, typed chat segments, and the QML-compat follow-up. They
carry their own notes in their commit messages; none has been described here.
Anything applied or retired is deleted from this directory by design.
- **0007 — render context occupancy; stop reading hyprctl errors as cursor.**
Needs the server carrying `f266136` first, or the pill honestly shows `—`.
- **0008 — owned message delegate.** Parity resolved by `df11bba`; see the
header. Apply with or after 0009.
- **0009 — speech: make stop actually stop, and re-synthesis actually
re-synthesize.** Three defects that all present as audio piling up and not
being callable back:
- `playProc` ran `sh -c "mpv … || ffplay …"`. A *compound* command means sh
does not exec-replace itself — it forks the player and waits — so
`running = false` SIGTERMs **sh** while the player keeps sounding as an
orphan. Reproduced directly: wrapper died, child survived. Now mpv is
invoked with no shell, so the pid quickshell holds is the pid making noise.
- `resynthesize()` added. `speak()` opens with a cache check keyed on the
text, and re-synthesis is by definition the *same text* — so the one
control that exists for "that came out wrong" was guaranteed to replay the
identical broken audio.
- `synthesizing`/`playing` split out of `speaking`, which could not tell
~11 s of synthesis from audible playback. `speaking` left untouched.
Lint identical before and after — introduced nothing. **No audio has been
played through it.**
## Recently closed
- **0005 — config: never store a null option.** Applied and committed as