Watch
1
0
Fork
You've already forked souveraine
0

shell: Speech service + sidebar Speak button (Read Aloud seam)

Speech.qml singleton: speak(text) reads the who->voice mapping live
from souveraine /v1/config (VoiceConfig tts_url + voice_id), POSTs
VibeVoice /audio/speech ({input, voice, model} -> mp3, same contract
as core/voice/client.rs) and plays via mpv/ffplay. The shell picks no
voice of its own. speech.tts.enable is the kill switch (button hidden
when off); speech.tts.endpoint overrides the mapped URL. Sidebar gains
a volume_up/stop toggle beside send that reads the last visible
assistant reply, skipping lock-redacted content.
This commit is contained in:
Fimeg 2026-07-21 13:28:47 -04:00
commit acc1161e71
4 changed files with 163 additions and 0 deletions

View file

@ -40,6 +40,7 @@ services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
services/SessionAudit.qml souveraine/services/SessionAudit.qml services/SessionAudit.qml souveraine/services/SessionAudit.qml
services/Speech.qml souveraine/services/Speech.qml
services/NotifyEvents.qml souveraine/services/NotifyEvents.qml services/NotifyEvents.qml souveraine/services/NotifyEvents.qml
services/CrashReporter.qml souveraine/services/CrashReporter.qml services/CrashReporter.qml souveraine/services/CrashReporter.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml

View file

@ -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) { function handleInput(inputText) {
if (inputText.startsWith(root.commandPrefix)) { if (inputText.startsWith(root.commandPrefix)) {
// Handle special commands // 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 RippleButton { // Send button
id: sendButton id: sendButton
Layout.alignment: Qt.AlignBottom Layout.alignment: Qt.AlignBottom

View file

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

View file

@ -8,6 +8,7 @@ singleton Idle 1.0 Idle.qml
singleton LockContentPolicy 1.0 LockContentPolicy.qml singleton LockContentPolicy 1.0 LockContentPolicy.qml
singleton NotifyEvents 1.0 NotifyEvents.qml singleton NotifyEvents 1.0 NotifyEvents.qml
singleton SessionAudit 1.0 SessionAudit.qml singleton SessionAudit 1.0 SessionAudit.qml
singleton Speech 1.0 Speech.qml
singleton SessiondBridge 1.0 SessiondBridge.qml singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessionEvents 1.0 SessionEvents.qml singleton SessionEvents 1.0 SessionEvents.qml
singleton Souveraine 1.0 Souveraine.qml singleton Souveraine 1.0 Souveraine.qml