Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/Face.qml
Fimeg c8dc24d3d1 face: follow the finger anywhere on the panel
Subscribes to viewtop's gaze feed and dispatches a synthetic pointer move on
the canvas, so live2d.js's own mousemove path drives head angle and eye
direction with its existing damping. Synthetic is the point: she is
deliberately not being given those contacts, and this lets her watch them
anyway.

Screen coordinates in, her window's coordinates out — the origin comes from
the compositor's furniture report rather than assumed, because the layout
decides it and it moves with the zone.
2026-08-07 01:01:05 -04:00

399 lines
16 KiB
QML

// Her face on the glass — TASK-59.
//
// The face is a *limb*, not a second client. `Souveraine.qml` is the one
// connection to the server and stays that way: this owns a `souveraine-web`
// process, feeds it what she is already saying on the sidebar's stream, and
// sends what the user says to it back through the same `Souveraine.send()`.
// Casey, 2026-08-05: *"I will want it to be in sync with the sidebar — meaning
// if we 'resume' it's resumed."* Two transports could not promise that; one
// does by construction.
//
// ## Turning her on is joining
//
// `joined` is the whole state. While it is true she is on the glass **and** the
// expression vocabulary rides in the per-send ambient block, so she has a
// syntax for shifting expression. While it is false neither happens — and the
// second half is the point: that prompt is context nobody asked for when the
// face is closed. Casey, 2026-08-05: *"it'll be like a loadable/unloadable
// skill… we might have times where we just don't want that extra prompt added
// to context."*
//
// The sidebar ignores the tags it sees, which is why they are safe to leave in
// the stream rather than stripped on the way to one surface.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
Singleton {
id: root
// On the glass, and in the prompt. One flag, both consequences.
property bool joined: false
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/face.sock"
property string rigDir: Quickshell.env("HOME") + "/.souveraine/face"
// Accumulated text of the turn in flight, so the bubble shows the whole
// line rather than the last delta.
property string _line: ""
// The fragment that rides in `ambient` while she is joined. Kept here
// rather than in the server so that leaving costs exactly nothing — there
// is no flag to unset and no prompt to remember to remove.
readonly property string skill: "You have a face on this device right now. "
+ "You may shift expression by emitting a tag on its own line: "
+ "[[face:idle]], [[face:alert]], [[face:thinking]], [[face:processing]], "
+ "[[face:affectionate]], [[face:straining]], [[face:yawning]], "
+ "[[face:listening]], [[face:speaking]]. "
+ "These are the postures the presence system already uses. "
+ "Use them sparingly, where the shift is real."
// No pre-flight check on the rig.
//
// There was one, reading `FileView.exists`, and it reported false for a
// directory that was plainly there — so the guard meant to explain a
// missing rig became the thing preventing a present one from loading. The
// host already fails loudly and specifically when the directory is wrong,
// and `onExited` puts `joined` back, so the honest answer is to let it try
// and report what actually happened. A guard that can be wrong about the
// world is worse than no guard.
function join() {
if (root.joined)
return;
host.running = true;
root.joined = true;
}
function leave() {
root.joined = false;
root._send({ op: "quit" });
host.running = false;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
// How big she is on the glass.
//
// She has to be **smaller than the usable zone**, and that is the whole
// reason this is set rather than left to the host's default. The
// compositor stands furniture up centred and lifted off the bottom edge —
// but it can only lift her into slack that exists, and at the default
// 540x960 she measured 997 tall against a 931 zone (GTK adds ~37px of
// titlebar even with decorations off), so she overflowed, had no slack,
// and sat pinned at the top with nowhere to go. Under the zone, and the
// lift is what puts her up by the clock instead of down on the dock.
// Her size on the glass, and the canvas is not a crop — it is her scale.
//
// The Cubism view fits the rig to the canvas, so shrinking the canvas
// shrinks *her*; it does not trim the empty margin around her. Tried on
// 2026-08-06: 640 to cut the ~100px of dead space under her feet, and it
// came back "a tiny version that's scaled odd" because the whole figure
// came down with it. The dead space is the rig's own layout (`center_y`
// and `width` in model.json), and moving it is a rig change, not a window
// one. 760 is the size that reads right.
readonly property string faceSize: "540x760"
Process {
id: host
command: ["souveraine-web",
"--rig", root.rigDir,
"--ipc", root.socketPath,
"--transparent",
"--size", root.faceSize,
"--app-id", "org.souveraine.face",
"--title", "Ani"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
return;
}
// Anything the page wants to say goes through the one transport.
if (msg.event === "said" && msg.text)
Souveraine.send(msg.text);
else if (msg.event === "tapped")
root.tapped(msg.area ?? "body");
else if (msg.event === "talk")
root._talk(msg.phase);
else if (msg.event === "dismiss")
root.leave();
else if (msg.event === "console")
root._pageSaid(msg.level, msg.text);
}
}
onExited: root.joined = false
}
signal tapped(string area)
// Whether she is recording right now. One flag, so a second press cannot
// start a second recorder over the first one's WAV.
property bool listening: false
// ## Holding her to speak
//
// Press and hold, speak, release: the utterance goes through the existing
// STT, the answer through the existing turn, and the sidebar logs both.
// She is the *face* of the voice pipeline, not a second chat surface
// (TASK-59 Q1a) — which is why nothing here holds a transcript or a
// conversation, it only hands text to `Souveraine.send()`.
//
// The recorder is `pw-record` at 16k mono s16 and the transcription is
// `souveraine-stt --file`, deliberately: that script already owns the
// endpoint from Settings → Speech and the whole error vocabulary
// (unreachable / 5xx / rejected), and a second copy of that here would be
// the second answer to "where does dictation go". 16k mono s16 is not a
// preference either — the comment in that script records that the server
// 500s on anything else.
//
// Written as one `sh -c` rather than a helper on PATH because the shell
// tree deploys as a unit and a new file on the device would need a package
// to reach it (CLAUDE.md's rule, and the trap that left sessiond five days
// stale). Two commands, one place.
function _talk(phase) {
if (phase === "start") {
if (root.listening)
return;
root.listening = true;
root._eval(`window.face.posture("listening")`);
recorder.command = ["sh", "-c",
"rm -f \"$W\"; pw-record --rate 16000 --channels 1 --format s16 \"$W\" & echo $! > \"$P\"; wait"];
recorder.running = true;
return;
}
if (!root.listening)
return;
root.listening = false;
recorder.running = false;
// Cancelled — a finger that slid off her, or the window losing focus.
// The recording is dropped rather than transcribed: sending whatever
// was captured before an abandoned gesture would put words she never
// finished into the conversation.
if (phase !== "end") {
stopper.command = ["sh", "-c", `kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P" "$W"`];
stopper.running = true;
root._eval(`window.face.posture("idle")`);
return;
}
// The 0.3s is not padding: pw-record finalises the WAV header on the
// way out, and reading it sooner gets a file the server rejects.
// `souveraine-stt` learned this the same way and its comment says so.
// The `tr`/`sed` is not tidying. Whisper wraps its output with
// embedded newlines, and the transcript arrives here through a
// `SplitParser` on "\n" — so four wrapped lines would be **four
// separate messages** sent to her, one turn each, instead of one
// utterance. `souveraine-stt` collapses them on its own typing leg
// and says why in a comment; `--file` prints them raw, so the same
// trap arrives by the other door and has to be closed on this side.
transcriber.command = ["sh", "-c",
`kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P"; sleep 0.4; ` +
`[ -s "$W" ] || exit 0; souveraine-stt --file "$W" ` +
`| tr '\\n\\r' ' ' | sed 's/ */ /g; s/^ //; s/ $//'; echo; rm -f "$W"`];
transcriber.running = true;
root._eval(`window.face.posture("thinking")`);
}
readonly property string _wav: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.wav"
readonly property string _pid: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.pid"
Process {
id: recorder
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: stopper
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: transcriber
environment: ({ W: root._wav, P: root._pid })
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const said = line.trim();
if (said.length === 0)
return;
// Straight into the one transport, so the sidebar logs it and
// the reply streams back to the bubble through the same
// `onStreamEvent` her own speech already uses.
Souveraine.send(said);
}
}
onExited: root._eval(`window.face.posture("idle")`)
}
// What the page says, where someone can see it.
//
// The host forwards console and errors on the same line protocol. Dropped
// here, a rig that fails to draw is silent in every direction — which it
// was, and it cost 2026-08-06 an afternoon: a missing `#live_talk` element
// threw on the runtime's first update, after the model and all four
// textures had loaded, so every other signal read healthy.
function _pageSaid(level, text) {
if (level === "error")
console.warn("[face] page error:", text);
else
console.log("[face]", text);
}
// Reachable by name, so the dial, a launcher and the agent all summon her
// the same way rather than each growing a copy (TASK-30/31). `status`
// answers rather than assumes — an agent that cannot ask whether she is up
// has to guess, and guessing is what a verb table exists to stop.
IpcHandler {
target: "face"
function toggle(): void {
root.toggle();
}
function join(): void {
root.join();
}
function leave(): void {
root.leave();
}
function status(): string {
return JSON.stringify({
joined: root.joined,
rig: root.rigDir
});
}
}
Socket {
id: sock
path: root.socketPath
connected: root.joined
}
function _send(msg) {
if (sock.connected)
sock.write(JSON.stringify(msg) + "\n");
}
// Where fingers are, straight from the compositor, so she can look at them.
//
// Only open while she is up, because the compositor throttles but does not
// stop: a feed nobody is reading is a socket buffer filling behind a face
// that is not on screen.
//
// Screen coordinates come in; her window's own coordinates go out. The
// compositor reports in logical panel pixels and the page thinks in CSS
// pixels inside her window, so the origin has to be subtracted or she
// looks at a point offset by however far down the panel she is standing.
Socket {
id: gaze
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
connected: root.joined
onConnectedChanged: {
if (gaze.connected)
gaze.write('{"op":"gaze"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
let m;
try {
m = JSON.parse(line);
} catch (e) {
return;
}
if (m.ok !== undefined && m.down === undefined)
return;
if (!m.down) {
root._eval("window.face.lookAway()");
return;
}
root._eval(`window.face.lookAt(${m.x - root.originX}, ${m.y - root.originY})`);
}
}
}
// Where her window sits on the panel. Read from the compositor's own
// furniture report rather than assumed, because the layout decides it and
// it moves with the zone.
property real originX: 0
property real originY: 0
Socket {
id: whereAmI
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
onConnectedChanged: {
if (whereAmI.connected)
whereAmI.write('{"op":"state"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const s = JSON.parse(line);
const f = (s.furniture ?? [])[0];
if (f?.at) {
root.originX = f.at.x;
root.originY = f.at.y;
}
} catch (e) {}
whereAmI.connected = false;
}
}
}
// Asked once she is up, and again a moment later: the first answer can
// land before the compositor has stood her up, and then her origin is
// whatever the last window left there.
Timer {
running: root.joined
interval: 2000
repeat: true
triggeredOnStart: true
onTriggered: whereAmI.connected = true
}
function _eval(script) {
root._send({ op: "eval", script: script });
}
// Everything she says on the sidebar's stream reaches the bubble. The face
// is a second *view* of one turn, never a second turn.
Connections {
target: Souveraine
enabled: root.joined
function onStreamEvent(event) {
if (event.message_type === "assistant_message" && event.content) {
root._line += event.content;
// Posture tags are hers to emit and the bubble's to not show.
const tag = /\[\[face:([a-z]+)\]\]/g;
let m;
while ((m = tag.exec(root._line)) !== null)
root._eval(`window.face.posture(${JSON.stringify(m[1])})`);
const shown = root._line.replace(tag, "").trim();
root._eval(`window.face.say(${JSON.stringify(shown)})`);
}
}
function onTurnActiveChanged() {
if (Souveraine.turnActive)
root._line = "";
}
}
}