Hold a key, speak, and the words are typed
Super+D holds the microphone open, releasing it transcribes on the GPU and types the result wherever the cursor is. Roughly 150ms for a normal utterance once the model is resident, measured rather than hoped for. Getting there meant discarding two approaches. Fedora 44 cannot install any GPU-capable Whisper for Python -- openai-whisper needs a numba that needs an llvmlite that does not exist for 3.14, and faster-whisper needs a ctranslate2 nobody packaged. The whisper-cpp package IS built with HIP but ships libraries with no binary and no bindings, and hand-writing ctypes for a large by-value struct is a segfault waiting for a version bump. So a container, as suggested. Vulkan rather than ROCm, and upstream's image rather than one built here. ROCm is seven gigabytes and serves AMD alone; Vulkan compute runs on the AMD, Intel and NVIDIA machines this config is used on, in a twentieth of the space. The Vulkan tag already contains whisper-server, so there is no Containerfile to keep working -- an earlier draft of this commit had one, and it was strictly worse. Two bugs found by using it rather than by reading it. Whisper describes silence as the literal text "[BLANK_AUDIO]", and the first working version pasted that string into the clipboard; a transcription that is nothing but such markers is now discarded. And the server answers with a line per segment, which typed into a window is an Enter press -- sending the half-written message, submitting the form. Whitespace is collapsed to one line. Neither the image nor the model is installed by ./install. Together they are over two gigabytes that want the network, and Settings offers both as one action instead. Nothing starts at login either: whisper-server holds the model from the moment it starts, so the first press of the key is what brings it up. The contract pins both text bugs, that the server stays on loopback, and that it does not start at login. Reverting the [BLANK_AUDIO] guard did not fail it at first -- the check was still correct, it had simply stopped being called -- so it now checks the call site too. Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dictation: the two pieces that have to be present before holding the key does
|
||||
// anything, and the download of the one that is not a package.
|
||||
//
|
||||
// Neither piece is installed by ./install, deliberately. The model is half a
|
||||
// gigabyte that no repository carries, and the speech server is a container
|
||||
// image compiled from upstream -- both are slow, both want the network, and an
|
||||
// installer that quietly does either is the surprise the interview exists to
|
||||
// prevent. So this reports what is missing and offers the part it can do.
|
||||
//
|
||||
// Status is read from the helper rather than inferred. Whether an image exists
|
||||
// and whether a server is answering are facts about the machine, and a page
|
||||
// that guessed at them would be confidently wrong on exactly the machine where
|
||||
// somebody is trying to work out why dictation is silent.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helper: Quickshell.shellDir + "/scripts/panama-dictate"
|
||||
|
||||
property bool modelInstalled: false
|
||||
property bool imageBuilt: false
|
||||
property bool serverReady: false
|
||||
property bool typingAvailable: false
|
||||
property int modelBytes: 0
|
||||
|
||||
property bool downloading: false
|
||||
property int downloadedBytes: 0
|
||||
property int downloadTotalBytes: 0
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool ready: root.modelInstalled && root.imageBuilt
|
||||
|
||||
readonly property real downloadFraction: root.downloadTotalBytes > 0
|
||||
? Math.min(1, root.downloadedBytes / root.downloadTotalBytes)
|
||||
: 0
|
||||
|
||||
// What is missing, in the order it has to be fixed. The image first: the
|
||||
// model is useless without something to run it, and telling somebody to
|
||||
// download half a gigabyte before mentioning they also need to build an
|
||||
// image would be two trips.
|
||||
readonly property string missing: {
|
||||
if (!root.imageBuilt)
|
||||
return "image";
|
||||
if (!root.modelInstalled)
|
||||
return "model";
|
||||
return "";
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusRun
|
||||
command: [root.helper, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const state = JSON.parse(this.text);
|
||||
root.modelInstalled = state.modelInstalled === true;
|
||||
root.imageBuilt = state.imageBuilt === true;
|
||||
root.serverReady = state.serverReady === true;
|
||||
root.typingAvailable = state.typingAvailable === true;
|
||||
root.modelBytes = state.modelBytes ?? 0;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read dictation status.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: downloadRun
|
||||
command: [root.helper, "download"]
|
||||
stdout: SplitParser {
|
||||
// One JSON object per line, roughly once a second, so the progress
|
||||
// bar moves without the helper flooding a pipe nobody drains.
|
||||
onRead: line => {
|
||||
try {
|
||||
const update = JSON.parse(line);
|
||||
if (update.ok === false) {
|
||||
root.lastError = "The model could not be downloaded.";
|
||||
return;
|
||||
}
|
||||
if (update.bytes !== undefined)
|
||||
root.downloadedBytes = update.bytes;
|
||||
if (update.total !== undefined)
|
||||
root.downloadTotalBytes = update.total;
|
||||
} catch (error) {
|
||||
// A line that is not JSON is not worth failing a download
|
||||
// over; the exit status is what decides.
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.downloading = false;
|
||||
if (exitCode !== 0 && root.lastError === "")
|
||||
root.lastError = "The model could not be downloaded.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!statusRun.running)
|
||||
statusRun.running = true;
|
||||
}
|
||||
|
||||
function download(): void {
|
||||
if (downloadRun.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.downloadedBytes = 0;
|
||||
root.downloadTotalBytes = 0;
|
||||
root.downloading = true;
|
||||
downloadRun.running = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
Reference in New Issue
Block a user