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:
parent
df11bbab96
commit
ba13d56afc
2 changed files with 188 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue