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:
parent
7e74ce37e3
commit
acc1161e71
4 changed files with 163 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
119
surfaces/quickshell/services/Speech.qml
Normal file
119
surfaces/quickshell/services/Speech.qml
Normal 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue