aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 11:20:12 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 11:20:12 +0200
commitc1fc2001219eb9aa349439b3007b57e1ced25335 (patch)
tree900e3fbc9da3a58d8f4e08f68040dd080c75ef84 /desktop
parent5ce0cdf77d19e74d85de8674e88b25db213e8534 (diff)
downloadquickshell-c1fc2001219eb9aa349439b3007b57e1ced25335.tar.gz
quickshell-c1fc2001219eb9aa349439b3007b57e1ced25335.zip
assistant: a mic button asks by voice
Each recording is a pw-record Process of its own, made from a Component like the Do row's runs, so a stopped recording's late exit cannot be read as the next one's. Stopping sets running to false (SIGTERM), which lets pw-record write the WAV header; the audio ask goes out from the process's exit, not from the click. Recording stops itself at 59 s: the daemon refuses over 60 s by file size, and the stop lands a little after the tick. Escape cancels a recording before it does anything else, and closing the card or losing the daemon cancels it too. A pw-record that ends on its own says "recording failed". Checked: a scratch harness saw transcribing, thinking, idle for a 2 s recording; by hand, every case in the spec passed with no pw-record left behind, a desktop shell reload mid-recording included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop')
-rw-r--r--desktop/assistant/Assistant.qml66
-rw-r--r--desktop/assistant/Card.qml24
-rw-r--r--desktop/assistant/README.md9
3 files changed, 93 insertions, 6 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 340c0cf..865f157 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -79,6 +79,7 @@ Scope {
onConnectedChanged: if (!root.connected) {
root.state = "idle";
root.runningTool = "";
+ root.endRecording(false);
if (root.opRunning && root.opDaemon) { root.opRunning = false; root.opError = "assistant gone"; }
}
@@ -107,10 +108,14 @@ Scope {
root.open = true;
}
- function close() { root.open = false; root.historyOpen = false; root.pickerOpen = false; }
- // Escape: the picker, then the history panel, then the card.
+ function close() {
+ root.endRecording(false);
+ root.open = false; root.historyOpen = false; root.pickerOpen = false;
+ }
+ // Escape: a recording, the picker, then the history panel, then the card.
function back() {
- if (root.pickerOpen) root.pickerOpen = false;
+ if (root.recording) root.endRecording(false);
+ else if (root.pickerOpen) root.pickerOpen = false;
else if (root.historyOpen) root.historyOpen = false;
else root.close();
}
@@ -200,6 +205,61 @@ Scope {
}
}
+ // The mic: pw-record into one file, overwritten by each recording and
+ // never deleted (the runtime dir goes at logout). The daemon has the
+ // model transcribe it. Spec: docs/superpowers/specs/2026-10-19-assistant-mic-design.md.
+ readonly property string micPath: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/assistant-mic.wav`
+ property var mic: null // the recording's Process, null when not recording
+ property int micSecs: 0
+ readonly property bool recording: root.mic !== null
+
+ function record() {
+ if (root.recording || !root.connected || root.busy || root.opRunning || root.blocked) return;
+ root.notice = "";
+ root.micSecs = 0;
+ root.mic = micRun.createObject(root);
+ }
+
+ // SIGTERM: pw-record finishes the header, then exited sends the ask.
+ // ponytail: a recording started before the last one's exit shares the
+ // file; pw-record exits within milliseconds of the signal.
+ function endRecording(send) {
+ if (!root.mic) return;
+ root.mic.send = send;
+ root.mic.running = false;
+ root.mic = null;
+ }
+
+ // 59, not 60: the daemon refuses over 60 s by file size, and the stop
+ // lands a little after the tick.
+ Timer {
+ running: root.recording
+ interval: 1000
+ repeat: true
+ onTriggered: if (++root.micSecs >= 59) root.endRecording(true);
+ }
+
+ Component {
+ id: micRun
+ Process {
+ id: rec
+ property bool send: false
+ running: true
+ command: ["pw-record", "--rate", "16000", "--channels", "1", "--format", "s16", root.micPath]
+ onExited: {
+ if (root.mic === rec) { // ended on its own: no mic, PipeWire down
+ root.mic = null;
+ root.notice = "recording failed";
+ } else if (rec.send && root.connected) {
+ root.chatting = true;
+ root.send({ type: "ask", text: "", audio: root.micPath,
+ files: root.staged.map(f => f.path) });
+ }
+ rec.destroy();
+ }
+ }
+ }
+
Process {
running: true
command: ["xdg-user-dir", "DOCUMENTS"]
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 4c76e64..d8826fa 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -431,10 +431,30 @@ Rectangle {
}
}
+ // nf-fa-microphone, checked in Inconsolata Nerd Font. Every state,
+ // entry included: SUPER+Home opens the card and the mic is one click away.
+ Button {
+ id: mic
+ anchors { bottom: parent.bottom; left: attach.right; leftMargin: attach.visible ? 8 : 0 }
+ height: box.height
+ icon: ""
+ text: card.canvas.recording
+ ? `${Math.floor(card.canvas.micSecs / 60)}:${String(card.canvas.micSecs % 60).padStart(2, "0")}`
+ : ""
+ danger: card.canvas.recording
+ enabled: card.canvas.connected && (card.canvas.recording
+ || (!card.canvas.busy && !card.canvas.opRunning && !card.canvas.blocked))
+ onClicked: {
+ if (card.canvas.recording) card.canvas.endRecording(true);
+ else card.canvas.record();
+ input.forceActiveFocus();
+ }
+ }
+
Rectangle {
id: box
anchors {
- bottom: parent.bottom; left: attach.right; leftMargin: attach.visible ? 8 : 0
+ bottom: parent.bottom; left: mic.right; leftMargin: 8
right: history.left; rightMargin: 8
}
// Grows with the text up to six lines, then scrolls.
@@ -483,6 +503,8 @@ Rectangle {
visible: input.text === ""
elide: Text.ElideRight
text: !card.canvas.connected ? "assistant not running"
+ : card.canvas.recording ? "Listening… click the mic to send"
+ : card.canvas.state === "transcribing" ? "Transcribing…"
: card.mode === "entry" ? "How can I help you?"
: card.canvas.staged.length ? `Ask about ${card.canvas.staged[0].name}…`
: "Message the assistant"
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index 41c01b3..bd61cb4 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -32,8 +32,13 @@ dropped on the island (`../island/`) starts a new one in the file state.
The paperclip opens a picker drawn in the card: a system dialog would open
beneath the overlay that holds the keyboard.
-Keys: Enter sends, Shift+Enter is a newline, Escape closes the picker, then
-the history panel, then the card. Stop replaces Send while a reply streams.
+The mic records with `pw-record` into `$XDG_RUNTIME_DIR/assistant-mic.wav`
+until clicked again (at most 59 s), then asks with it: the daemon has the
+model transcribe it and answers the words.
+
+Keys: Enter sends, Shift+Enter is a newline, Escape cancels a recording,
+then closes the picker, the history panel, then the card. Stop replaces
+Send while a reply streams.
Notes: