diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 29fb378..4334804 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -40,6 +40,7 @@ services/SessionEvents.qml souveraine/services/SessionEvents.qml services/SessiondBridge.qml souveraine/services/SessiondBridge.qml services/StepUpAuth.qml souveraine/services/StepUpAuth.qml services/SessionAudit.qml souveraine/services/SessionAudit.qml +services/Speech.qml souveraine/services/Speech.qml services/NotifyEvents.qml souveraine/services/NotifyEvents.qml services/CrashReporter.qml souveraine/services/CrashReporter.qml modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml diff --git a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml index 9690aa0..a76ef6e 100644 --- a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml +++ b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml @@ -211,6 +211,20 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) }, ] + // Latest visible assistant reply — what the Speak button reads aloud. + // Skips lock-redacted content (LockContentPolicy swaps it for a marker). + function lastAssistantText() { + for (let i = Ai.messageIDs.length - 1; i >= 0; i--) { + const m = Ai.messageByID[Ai.messageIDs[i]]; + if (m && m.role === "assistant" && (m.visibleToUser ?? true) + && m.content && m.content.length > 0 + && !m.content.startsWith("[content hidden")) { + return m.content; + } + } + return ""; + } + function handleInput(inputText) { if (inputText.startsWith(root.commandPrefix)) { // Handle special commands @@ -708,6 +722,34 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) } } } + RippleButton { // Speak button — read the last reply aloud + id: speakButton + visible: Speech.enabled + Layout.alignment: Qt.AlignBottom + Layout.rightMargin: 3 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: Speech.speaking || root.lastAssistantText().length > 0 + toggled: Speech.speaking + + MouseArea { + anchors.fill: parent + cursorShape: speakButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (Speech.speaking) Speech.stop(); + else Speech.speak(root.lastAssistantText()); + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: 22 + color: Speech.speaking ? Appearance.m3colors.m3onPrimary : (speakButton.enabled ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnLayer2Disabled) + text: Speech.speaking ? "stop_circle" : "volume_up" + } + } RippleButton { // Send button id: sendButton Layout.alignment: Qt.AlignBottom diff --git a/surfaces/quickshell/services/Speech.qml b/surfaces/quickshell/services/Speech.qml new file mode 100644 index 0000000..79ad842 --- /dev/null +++ b/surfaces/quickshell/services/Speech.qml @@ -0,0 +1,119 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Speech — TTS egress for the shell (the Read Aloud seam, TASK-18). + * + * Souveraine owns the who→voice mapping: VoiceConfig in the consciousness + * config carries tts_url and voice_id, served live at /v1/config. This + * service reads that mapping on every speak() and posts the text to + * VibeVoice's /audio/speech (JSON {input, voice, model}, mp3 back — the + * same contract core/voice/client.rs speaks), then plays the result. The + * shell picks no voice of its own; souveraine decides how she sounds. + * + * Config.options.speech.tts.enable is the user's kill switch (settings → + * Speech); Config.options.speech.tts.endpoint, when set, overrides the + * server-mapped tts_url. + */ +Singleton { + id: root + + readonly property bool enabled: Config.options?.speech?.tts?.enable ?? false + property bool speaking: synthProc.running || playProc.running + property string lastError: "" + + property string _pendingText: "" + readonly property string _outFile: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/souveraine-speech.mp3" + + // speak — synthesize and play `text` in the agent's voice. A new call + // replaces any in-flight synthesis or playback. + function speak(text) { + const t = String(text ?? "").trim(); + if (t.length === 0 || !root.enabled) return; + stop(); + root.lastError = ""; + root._pendingText = t; + voiceLookup.running = true; + } + + function stop() { + voiceLookup.running = false; + synthProc.running = false; + playProc.running = false; + } + + // ── who → voice, from souveraine ───────────────────────────────────── + Process { + id: voiceLookup + command: ["curl", "-sf", "--max-time", "3", `${Souveraine.serverBase}/v1/config`] + stdout: StdioCollector { + onStreamFinished: { + let ttsUrl = Config.options?.speech?.tts?.endpoint ?? ""; + let voice = ""; + try { + const cfg = JSON.parse(text); + voice = cfg?.voice?.voice_id ?? ""; + if (ttsUrl.length === 0) + ttsUrl = cfg?.voice?.tts_url ?? ""; + } catch (e) { + console.log("[Speech] could not parse /v1/config:", e); + } + if (ttsUrl.length === 0) { + root.lastError = "no TTS endpoint (server unmapped, shell override empty)"; + console.log("[Speech]", root.lastError); + return; + } + synthProc.command = [ + "curl", "-sf", "--max-time", "60", + "-X", "POST", "-H", "Content-Type: application/json", + "-d", JSON.stringify({ + input: root._pendingText, + voice: voice, + model: "vibevoice-v1" + }), + "-o", root._outFile, + `${ttsUrl.replace(/\/$/, "")}/audio/speech` + ]; + synthProc.running = true; + } + } + onExited: (exitCode) => { + if (exitCode !== 0) { + root.lastError = "souveraine server unreachable for voice mapping"; + console.log("[Speech]", root.lastError); + } + } + } + + // ── synthesis ──────────────────────────────────────────────────────── + Process { + id: synthProc + onExited: (exitCode) => { + if (exitCode === 0) { + playProc.running = true; + } else { + root.lastError = `TTS synthesis failed (curl exit ${exitCode})`; + console.log("[Speech]", root.lastError); + } + } + } + + // ── playback ───────────────────────────────────────────────────────── + Process { + id: playProc + command: ["sh", "-c", + `mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` + + `|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`] + onExited: (exitCode) => { + if (exitCode !== 0) { + root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`; + console.log("[Speech]", root.lastError); + } + } + } +} diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index b0d214c..ac04e1f 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -8,6 +8,7 @@ singleton Idle 1.0 Idle.qml singleton LockContentPolicy 1.0 LockContentPolicy.qml singleton NotifyEvents 1.0 NotifyEvents.qml singleton SessionAudit 1.0 SessionAudit.qml +singleton Speech 1.0 Speech.qml singleton SessiondBridge 1.0 SessiondBridge.qml singleton SessionEvents 1.0 SessionEvents.qml singleton Souveraine 1.0 Souveraine.qml