diff options
Diffstat (limited to 'desktop/assistant')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 66 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 24 | ||||
| -rw-r--r-- | desktop/assistant/README.md | 9 |
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: |
