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.
136 lines
6.6 KiB
Diff
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
|
|
|