Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/patches/0009-speech-stop-and-resynthesize.patch
Fimeg ba13d56afc 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.
2026-08-12 13:55:35 -04:00

136 lines
6.6 KiB
Diff

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