Localized bar strings, signal-gated listening, warm microphone, animation styles

- The bar speaks the language being dictated ("Ouvindo…", "Escuchando…", …).
- Green now means real audio is arriving, not just that pw-record connected:
  a Bluetooth microphone sends silence while it switches profile, which is
  where the first words were being lost.
- Advanced → "Keep the microphone open": the stream is parked between
  recordings and a recording starts instantly with ~0.6 s of pre-roll.
- Settings → "Bar animation": bars, wave, pulse or dots.
- pw-record is shut down off the event loop so a cancel can never stall.
This commit is contained in:
2026-09-07 00:31:14 +01:00
parent a07082f17b
commit 15cd5116a2
5 changed files with 314 additions and 50 deletions
+30 -6
View File
@@ -106,6 +106,7 @@ Panel {
return ""
}
readonly property string defaultLang: langs.length ? langs[0].code : ""
function t(key, fallback) { var st = svc ? svc.strings : null; return st && st[key] ? st[key] : fallback }
readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent"
function clock(secs) {
var s = Math.floor(secs || 0)
@@ -122,17 +123,17 @@ Panel {
function subtitle() {
if (!svc) return "Service not loaded"
if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…"
if (connecting) return "Opening the microphone…"
if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
if (transcribing) return "Transcribing…"
if (connecting) return t("opening", "Opening the microphone…")
if (recording) return t("listening", "Listening…").replace(/…$/, "") + " · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
if (transcribing) return t("transcribing", "Transcribing…")
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
return "Idle · " + svc.langLabel
}
readonly property string liveText: {
if (!svc) return ""
if (connecting) return "Opening microphone…"
if (recording) return svc.partial !== "" ? svc.partial : "Listening…"
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…")
if (connecting) return t("opening", "Opening microphone…")
if (recording) return svc.partial !== "" ? svc.partial : t("listening", "Listening…")
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : t("transcribing", "Transcribing…"))
if (showError) return svc.error
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
return ""
@@ -173,6 +174,7 @@ Panel {
Waveform {
anchors.verticalCenter: parent.verticalCenter
height: Style.bar.iconCanvas
style: ["bars", "wave", "pulse", "dots"].indexOf(String(root.cfg.animation)) >= 0 ? String(root.cfg.animation) : "bars"
bars: 18
levels: root.svc ? root.svc.levels : []
sine: root.connecting || (root.download !== null && !root.busy)
@@ -1104,6 +1106,21 @@ Panel {
checked: root.cfg.keepAudio !== false
onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false)
}
Row {
width: parent.width
spacing: Style.space(8)
RowLabel { text: "Bar animation" }
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
showLabel: false
enabled: root.connected
value: String(root.cfg.animation || "bars")
options: [ { value: "bars", label: "Bars" }, { value: "wave", label: "Wave" }, { value: "pulse", label: "Pulse" }, { value: "dots", label: "Dots" } ]
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("animation", v); value = Qt.binding(function() { return String(root.cfg.animation || "bars") }) }
}
}
Row {
width: parent.width
spacing: Style.space(8)
@@ -1265,6 +1282,13 @@ Panel {
}
}
Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." }
SwitchRow {
label: "Keep the microphone open"
summary: checked ? "instant start, half a second of pre-roll" : "opens on each key press"
checked: !!root.cfg.warmMic
onToggled: if (root.svc) root.svc.setSetting("warmMic", !root.cfg.warmMic)
}
Note { text: "The stream stays open between recordings, so the first words are never missed: the recording even includes the moment before the key press. With a Bluetooth headset this keeps it in headset mode all the time, so pair it with a wired or USB microphone above." }
Row {
width: parent.width
spacing: Style.space(8)
+2
View File
@@ -39,6 +39,8 @@ Item {
readonly property string agentName: state.agentName || "" // omarchy's default coding agent
readonly property bool agentMode: !!state.agentMode // this recording goes to the agent
readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…)
readonly property var strings: state.strings || ({}) // bar wording in the language being dictated
readonly property bool warm: !!state.warm // the microphone stream is kept open between recordings
property var sources: [] // microphones (sent with the greeting and on `get`)
// ---- history (fetched on demand; the daemon says when it changed) ----
+113 -17
View File
@@ -1,30 +1,39 @@
import QtQuick
// A row of thin bars mirrored around the middle. `levels` holds 0..1 values,
// newest last; the bars show the most recent `bars` of them. With `idle` on
// (nothing to show yet, or transcribing) the bars breathe gently instead.
// The bar's picture of the microphone, in one of four styles:
// bars thin bars mirrored around the middle (the default)
// wave a smooth sine-like line whose swell follows the voice
// pulse a dot inside a ring that breathes with the voice
// dots a row of dots that grow with the voice
// `levels` holds 0..1 values, newest last. With `sine` on (the microphone is
// still opening) every style shows a travelling wave; with `idle` on
// (transcribing, or an error) it breathes gently instead.
Item {
id: root
property var levels: []
property string style: "bars"
property int bars: 20
property real barWidth: 2
property real gap: 2
property color color: "white"
property bool idle: false // gentle breathing (nothing to show yet, or transcribing)
property bool sine: false // a travelling sine wave (the microphone is still connecting)
property bool idle: false
property bool sine: false
property real minHeight: 2
implicitWidth: bars * (barWidth + gap) - gap
readonly property bool animated: idle || sine || style === "wave" || style === "pulse"
implicitWidth: style === "pulse" ? height + 6 : bars * (barWidth + gap) - gap
implicitHeight: 16
property real phase: 0
Timer {
interval: 40
repeat: true
running: (root.idle || root.sine) && root.visible
onTriggered: root.phase += root.sine ? 0.35 : 0.2
running: root.animated && root.visible
onTriggered: { root.phase += root.sine ? 0.35 : 0.2; if (root.style === "wave") waveCanvas.requestPaint() }
}
onLevelsChanged: if (style === "wave") waveCanvas.requestPaint()
onStyleChanged: if (style === "wave") waveCanvas.requestPaint()
function levelAt(i) {
var lv = levels || []
@@ -32,23 +41,28 @@ Item {
var v = offset + i >= 0 ? Number(lv[offset + i]) : 0
return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0
}
function breath(i) {
return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2
}
function wave(i) {
return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2
function breath(i) { return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 }
function travel(i) { return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2 }
function amp(i) { return sine ? travel(i) : idle ? breath(i) : levelAt(i) }
// The loudest of the last few levels, smoothed: what a single shape should show.
function recent() {
if (sine) return 0.4 + 0.35 * (1 + Math.sin(phase * 1.5)) / 2
if (idle) return 0.15 + 0.1 * (1 + Math.sin(phase)) / 2
var lv = levels || [], m = 0
for (var i = Math.max(0, lv.length - 4); i < lv.length; i++) m = Math.max(m, Number(lv[i]) || 0)
return Math.min(1, m)
}
// ---- bars ----
Row {
visible: root.style === "bars"
anchors.centerIn: parent
spacing: root.gap
Repeater {
model: root.bars
model: root.style === "bars" ? root.bars : 0
Rectangle {
required property int index
readonly property real lv: root.sine ? root.wave(index) : root.idle ? root.breath(index) : root.levelAt(index)
readonly property real lv: root.amp(index)
width: root.barWidth
height: Math.max(root.minHeight, Math.round(lv * root.height))
radius: root.barWidth / 2
@@ -58,4 +72,86 @@ Item {
}
}
}
// ---- dots ----
Row {
visible: root.style === "dots"
anchors.centerIn: parent
spacing: 0
readonly property int count: Math.max(4, Math.round(root.bars / 2))
readonly property real slot: root.implicitWidth / count
Repeater {
model: root.style === "dots" ? parent.count : 0
Item {
required property int index
readonly property real lv: root.amp(Math.min(root.bars - 1, Math.round(index * root.bars / parent.count + root.bars / parent.count / 2)))
width: parent.slot
height: root.height
Rectangle {
anchors.centerIn: parent
width: Math.max(2, Math.round(2 + lv * (Math.min(root.height, parent.width) - 3)))
height: width
radius: width / 2
color: root.color
opacity: 0.5 + 0.5 * lv
Behavior on width { NumberAnimation { duration: 70 } }
}
}
}
}
// ---- wave ----
Canvas {
id: waveCanvas
visible: root.style === "wave"
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
if (!visible) return
var mid = height / 2, n = root.bars, span = width / Math.max(1, n - 1)
ctx.strokeStyle = root.color
ctx.lineWidth = 1.6
ctx.lineCap = "round"
ctx.beginPath()
var steps = Math.max(24, Math.round(width))
for (var s = 0; s <= steps; s++) {
var x = width * s / steps
var pos = x / span, i = Math.floor(pos), f = pos - i
var a0 = root.amp(Math.min(n - 1, i)), a1 = root.amp(Math.min(n - 1, i + 1))
var envelope = a0 + (a1 - a0) * f
var y = mid + Math.sin(x * 0.55 - root.phase * 2) * envelope * (mid - 1)
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
}
ctx.stroke()
}
}
// ---- pulse ----
Item {
visible: root.style === "pulse"
anchors.centerIn: parent
width: root.height + 6
height: root.height
readonly property real lv: root.recent()
Rectangle { // ring
anchors.centerIn: parent
width: Math.round(root.height * (0.45 + 0.55 * parent.lv))
height: width
radius: width / 2
color: "transparent"
border.width: 1.5
border.color: root.color
opacity: 0.35 + 0.65 * parent.lv
Behavior on width { NumberAnimation { duration: 90 } }
}
Rectangle { // dot
anchors.centerIn: parent
width: Math.round(root.height * (0.2 + 0.3 * parent.lv))
height: width
radius: width / 2
color: root.color
Behavior on width { NumberAnimation { duration: 60 } }
}
}
}