Watch
1
0
Fork
You've already forked souveraine
0

shell: land the owned message delegate and the speech fixes

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.
This commit is contained in:
Fimeg 2026-08-12 14:50:57 -04:00
commit 93fe30a8e7
4 changed files with 62 additions and 182 deletions

View file

@ -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 whovoice 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);
}
}