532 lines
21 KiB
QML
532 lines
21 KiB
QML
import QtQuick
|
|
import Quickshell.Io
|
|
import qs.config
|
|
import qs.services
|
|
|
|
// Dictation is an input method, so it lives under Input beside the keyboard —
|
|
// but it listens through whichever device the Sound page selects, so the
|
|
// microphone card below hands off there rather than duplicating the picker.
|
|
//
|
|
// Status first. The two questions this page exists to answer are "is it ready"
|
|
// and "which keys" -- both were previously spelled as a pair of rows reading
|
|
// "Ready / Missing" beside a hardcoded sentence about Super+D. The hotkeys are
|
|
// now looked up from the live keymap, so rebinding the dictation key changes
|
|
// what this page says instead of quietly making it wrong.
|
|
SettingsPage {
|
|
id: root
|
|
|
|
// Chords come from the compositor by matching the descriptions
|
|
// hypr/keybinds.lua gives the dictation binds. The literals are a fallback
|
|
// for the moment before the keymap has loaded, not a second source of truth.
|
|
function chordFor(needle: string, fallback: string): string {
|
|
for (const bind of Keybinds.binds) {
|
|
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
|
|
return String(bind.chord);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D")
|
|
readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D")
|
|
|
|
readonly property int modelMegabytes: Math.round(Dictation.modelBytes / 1048576)
|
|
|
|
// What the setup is doing, as steps rather than a percentage with no
|
|
// subject. Driven entirely by the service's existing phase and progress
|
|
// fields -- nothing new is asked of panama-dictate.
|
|
readonly property var setupSteps: [
|
|
{
|
|
number: 1,
|
|
title: "Container image",
|
|
detail: Dictation.imageBuilt
|
|
? "The speech server is on this machine"
|
|
: (Dictation.phase === "pulling" ? "Fetching the speech server…" : "About 1 GB, pulled once"),
|
|
done: Dictation.imageBuilt,
|
|
active: Dictation.phase === "pulling",
|
|
progress: -1
|
|
},
|
|
{
|
|
number: 2,
|
|
title: "Speech model",
|
|
detail: Dictation.modelInstalled
|
|
? root.modelMegabytes + " MB, kept across rebuilds of the server"
|
|
: (Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
|
|
? "Downloading — " + Math.round(Dictation.downloadedBytes / 1048576)
|
|
+ " of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB"
|
|
: "About 490 MB, downloaded once"),
|
|
done: Dictation.modelInstalled,
|
|
active: Dictation.phase === "downloading",
|
|
progress: Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
|
|
? Dictation.downloadFraction
|
|
: -1
|
|
},
|
|
{
|
|
number: 3,
|
|
title: "First transcription",
|
|
detail: Dictation.serverReady
|
|
? "The speech server is answering"
|
|
: "The server starts itself the first time you hold the key",
|
|
done: Dictation.serverReady,
|
|
active: false,
|
|
progress: -1
|
|
}
|
|
]
|
|
|
|
// ── The on-page test ────────────────────────────────────────────────────
|
|
// panama-dictate types what it heard into whatever has keyboard focus, so
|
|
// a test needs no new plumbing: the field below takes focus, the helper
|
|
// runs exactly as the hotkey runs it, and the words arrive here. The helper
|
|
// is addressed through the path the Dictation service already publishes
|
|
// rather than one spelled again in this file.
|
|
|
|
// True only once the helper has confirmed a recording actually started.
|
|
//
|
|
// It used to be set on the press, which made it a claim rather than a fact:
|
|
// `panama-dictate start` refuses with {"ok":false,"error":"already-recording"}
|
|
// when the hotkey is already holding the microphone, and the field said
|
|
// "Listening…" over that refusal. Worse, the button then read "Stop and
|
|
// type it" -- so the next press stopped the hotkey's recording and typed
|
|
// somebody else's words into this test field.
|
|
property bool listening: false
|
|
|
|
// Why the last press did not do what it said, in this page's words.
|
|
property string testError: ""
|
|
|
|
// The helper answers with a code, not a sentence, so the words live here.
|
|
// Anything unrecognised is shown as itself rather than swallowed.
|
|
readonly property var dictateReasons: ({
|
|
"already-recording": "Something is already listening — the dictation hotkey, most likely. Let that finish first.",
|
|
"not-recording": "Nothing was listening, so there was nothing to type.",
|
|
"not-downloaded": "The speech model has not been downloaded yet.",
|
|
"no-image": "The speech server is not installed yet.",
|
|
"server-unavailable": "The speech server did not answer.",
|
|
"transcribe-failed": "That could not be transcribed.",
|
|
"too-short": "That was too short to transcribe.",
|
|
"no-speech": "Nothing was said.",
|
|
"deliver-failed": "The words could not be typed into the field.",
|
|
"unknown-command": "The dictation helper did not understand that."
|
|
})
|
|
|
|
Process {
|
|
id: dictateRun
|
|
|
|
// Which action this run was, so its reply can be read against it.
|
|
property string action: ""
|
|
|
|
stdout: StdioCollector { onStreamFinished: root.absorbDictate(this.text) }
|
|
onExited: (exitCode, exitStatus) => Dictation.refresh()
|
|
}
|
|
|
|
// The helper's reply decides what happened. A start that was refused leaves
|
|
// this page exactly as it was, with the refusal on screen.
|
|
function absorbDictate(text: string): void {
|
|
let reply = null;
|
|
try {
|
|
reply = JSON.parse(text);
|
|
} catch (error) {
|
|
reply = null;
|
|
}
|
|
const ok = reply?.ok === true;
|
|
root.listening = dictateRun.action === "start" && ok;
|
|
if (ok) {
|
|
root.testError = "";
|
|
return;
|
|
}
|
|
const code = String(reply?.error ?? "");
|
|
if (code === "")
|
|
root.testError = "The dictation helper did not answer.";
|
|
else
|
|
root.testError = root.dictateReasons[code] !== undefined
|
|
? String(root.dictateReasons[code])
|
|
: code;
|
|
}
|
|
|
|
function runDictate(action: string): void {
|
|
if (dictateRun.running)
|
|
return;
|
|
root.testError = "";
|
|
dictateRun.action = action;
|
|
dictateRun.command = [Dictation.helper, action];
|
|
dictateRun.running = true;
|
|
}
|
|
|
|
title: "Dictation"
|
|
lede: "Local speech to text — the audio, the model and the server never leave this machine."
|
|
|
|
// ── Ready ───────────────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
visible: Dictation.ready
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 72
|
|
|
|
Rectangle {
|
|
id: readyTile
|
|
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 44
|
|
height: 44
|
|
radius: 14
|
|
color: Theme.alpha(Theme.ok, 0.10)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.ok, 0.35)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "✓"
|
|
color: Theme.ok
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge + 4
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.left: readyTile.right
|
|
anchors.leftMargin: 16
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Dictation is ready"
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Speech model · " + root.modelMegabytes + " MB · "
|
|
+ (Dictation.serverReady
|
|
? "the speech server is running"
|
|
: "the speech server starts on your first dictation")
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Hold to dictate"
|
|
detail: "Release to type what you said, wherever the cursor is"
|
|
controlWidth: 220
|
|
|
|
KeycapChord {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
chord: root.holdChord
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Cancel a dictation"
|
|
detail: "Throws the recording away without transcribing it"
|
|
controlWidth: 220
|
|
|
|
KeycapChord {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
chord: root.cancelChord
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Try it"
|
|
// The refusal takes the detail's place while there is one, so a
|
|
// press that did nothing says so where the press happened.
|
|
detail: root.testError !== ""
|
|
? root.testError
|
|
: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
|
|
controlWidth: 340
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 200
|
|
height: 30
|
|
radius: 8
|
|
color: Theme.alpha(Theme.fg, 0.05)
|
|
border.width: 1
|
|
border.color: heard.activeFocus
|
|
? Theme.alpha(Theme.accent, 0.55)
|
|
: Theme.alpha(Theme.fg, 0.12)
|
|
|
|
TextInput {
|
|
id: heard
|
|
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
clip: true
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
selectByMouse: true
|
|
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: heard.text === ""
|
|
text: root.listening ? "Listening…" : "Dictated text lands here"
|
|
color: Theme.fgMuted
|
|
font: heard.font
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// Four states, because stopping is not instant: the helper
|
|
// transcribes before it types, and a button still reading
|
|
// "Stop and type it" through those seconds invites a second
|
|
// press for a stop that has already been asked for.
|
|
text: {
|
|
if (dictateRun.running)
|
|
return root.listening ? "Transcribing…" : "Starting…";
|
|
return root.listening ? "Stop and type it" : "Test dictation";
|
|
}
|
|
tone: root.listening ? "accent" : "normal"
|
|
enabled: !dictateRun.running
|
|
onClicked: {
|
|
// Focus first, and keep it: the helper types with wtype
|
|
// into whatever holds keyboard focus when it finishes.
|
|
heard.forceActiveFocus();
|
|
if (root.listening) {
|
|
root.runDictate("stop");
|
|
return;
|
|
}
|
|
heard.text = "";
|
|
// `listening` is set by the reply, not by this press.
|
|
root.runDictate("start");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
visible: !Dictation.typingAvailable
|
|
label: "Typing"
|
|
detail: "wtype is missing, so dictated text goes to the clipboard instead of being typed"
|
|
value: "Missing"
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── Setting up ──────────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
visible: !Dictation.ready
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 72
|
|
|
|
Rectangle {
|
|
id: setupTile
|
|
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 44
|
|
height: 44
|
|
radius: 14
|
|
color: Dictation.downloading
|
|
? Theme.alpha(Theme.accent, 0.10)
|
|
: Theme.alpha(Theme.fg, 0.07)
|
|
border.width: 1
|
|
border.color: Dictation.downloading
|
|
? Theme.alpha(Theme.accent, 0.35)
|
|
: Theme.alpha(Theme.fg, 0.16)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: Dictation.downloading ? "…" : "✗"
|
|
color: Dictation.downloading ? Theme.accent : Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge + 4
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.left: setupTile.right
|
|
anchors.leftMargin: 16
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: Dictation.downloading ? "Setting up dictation" : "Dictation is not set up yet"
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Everything stays local: a speech server in a container, and a Whisper model. Neither ships with Panama — both are large and want the network."
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: root.setupSteps
|
|
|
|
Item {
|
|
id: step
|
|
|
|
required property var modelData
|
|
|
|
width: parent ? parent.width : 620
|
|
height: 48
|
|
|
|
Rectangle {
|
|
id: number
|
|
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 24
|
|
height: 24
|
|
radius: 12
|
|
color: {
|
|
if (step.modelData.done)
|
|
return Theme.alpha(Theme.ok, 0.15);
|
|
if (step.modelData.active)
|
|
return Theme.alpha(Theme.accent, 0.20);
|
|
return Theme.alpha(Theme.fg, 0.08);
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: step.modelData.done ? "✓" : String(step.modelData.number)
|
|
color: {
|
|
if (step.modelData.done)
|
|
return Theme.ok;
|
|
if (step.modelData.active)
|
|
return Theme.accent;
|
|
return Theme.fgDim;
|
|
}
|
|
font.family: Theme.fontFamily
|
|
font.features: Theme.tabularFigures
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.DemiBold
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.left: number.right
|
|
anchors.leftMargin: 12
|
|
anchors.right: progress.left
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: String(step.modelData.title)
|
|
color: step.modelData.done || step.modelData.active ? Theme.fg : Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.weight: Font.Medium
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: String(step.modelData.detail)
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
// A real bar rather than a spinner: this is the one part of
|
|
// setup whose length is actually known.
|
|
Rectangle {
|
|
id: progress
|
|
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: step.modelData.progress >= 0 ? 140 : 0
|
|
height: 5
|
|
radius: 3
|
|
visible: step.modelData.progress >= 0
|
|
color: Theme.alpha(Theme.fg, 0.09)
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: parent.width * Math.max(0, Math.min(1, step.modelData.progress))
|
|
radius: parent.radius
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0.0; color: Theme.accent }
|
|
GradientStop { position: 1.0; color: Theme.accentSecondary }
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: 1
|
|
color: Theme.alpha(Theme.fg, 0.05)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ONE action that actually works: panama-dictate setup pulls the
|
|
// server image and downloads the model together. This card used to
|
|
// offer a Download button wired to a command the helper does not have,
|
|
// so nothing here did anything. It does now.
|
|
ActionRow {
|
|
label: "Set up dictation"
|
|
detail: Dictation.downloading
|
|
? "Working — this can take several minutes on a slow connection"
|
|
: "Fetches the speech server and the model. Runs once, keeps both."
|
|
action: Dictation.downloading ? "Working…" : "Set up"
|
|
enabled: !Dictation.downloading
|
|
onTriggered: Dictation.setup()
|
|
divider: Dictation.lastError !== ""
|
|
}
|
|
|
|
TextRow {
|
|
visible: Dictation.lastError !== ""
|
|
label: "Problem"
|
|
detail: Dictation.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Microphone"
|
|
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
|
|
|
ActionRow {
|
|
label: "Input device"
|
|
detail: "Dictation listens through the input device selected in Sound"
|
|
divider: false
|
|
action: "Open Sound"
|
|
onTriggered: ShellState.openSettings("sound")
|
|
}
|
|
}
|
|
}
|