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/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue