Skip to content

Commit 2699a2d

Browse files
committed
fix: silence-timeout-ms now applies live, as the config promises
The generated config header says edits apply live except the hotkey. Verified on hardware that this was false for silence-timeout-ms: editing it and latching capture left the old timeout in force until restart, because the pipeline read the value from a Config copied at startup and the reload had nowhere to land. That is the safety net which force-closes the microphone when a tap-to-latch capture is never ended. The setting that bounds how long we may be listening is the last one that should require restarting the listener -- noticing a hot mic and shortening the timeout did nothing. Fixed the same way sensitivity was, whose comments describe this exact bug: an atomic on RuntimeShared, published by the menu host on every reload, read through a live closure the pipeline consults per tick. The documentation workaround from the previous commit is reverted, since the promise is now true. Regression test mirrors live_sensitivity_overrides_the_startup_snapshot: proves the pipeline READS the live value, which is the half that was broken before.
1 parent f3a073c commit 2699a2d

6 files changed

Lines changed: 105 additions & 8 deletions

File tree

‎crates/config/src/paths.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,8 @@ const PREAMBLE: &str = concat!(
289289
"# OutLoud configuration. Every setting below is shown at its built-in\n",
290290
"# default and commented out; uncomment a line to change it.\n",
291291
"#\n",
292-
"# Most edits apply live (no restart). Two do not: the hotkey, which\n",
293-
"# binds at launch, and silence-timeout-ms, which the pipeline reads\n",
294-
"# once when capture starts. Full reference: docs/configuration.md\n\n",
292+
"# Edits apply live (no restart) except the hotkey, which binds at\n",
293+
"# launch. Full reference: docs/configuration.md\n\n",
295294
);
296295

297296
/// Every key and its current default, commented out.

‎crates/config/src/schema.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,7 @@ pub fn schema() -> &'static [KeySpec] {
284284
doc: "Safety net: force-commit and close the microphone after \
285285
capture has run this long. Push-to-talk ends on key \
286286
release; tap-to-latch waits for a second tap that may \
287-
never come. Read once at launch: unlike most settings, \
288-
an edit here needs a restart to take effect.",
287+
never come.",
289288
wired: true,
290289
},
291290
KeySpec {

‎crates/outloud/src/main.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,15 @@ fn main() -> anyhow::Result<()> {
915915
.as_ref()
916916
.map(|h| h.silence_timeout_ms())
917917
.unwrap_or_else(outloud::menuhost::MenuHost::silence_timeout_from_config),
918+
// A live view of the same setting, for the same reason as
919+
// sensitivity above and with more at stake: this is the net that
920+
// force-closes a latched microphone, and until it was published
921+
// here, shortening it after noticing a hot mic did nothing until
922+
// restart. `--once` has no menu host and keeps the startup value.
923+
live_hot_mic_timeout_ms: menu_host.as_ref().map(|_| {
924+
let runtime = runtime.clone();
925+
std::sync::Arc::new(move || runtime.hot_mic_timeout_ms()) as _
926+
}),
918927
warm_hold_ms: menu_host.as_ref().map_or(0, |h| h.warm_hold_ms()),
919928
// Per-app profiles need a live config; `--once` has no menu host
920929
// and therefore no profiles, which keeps its numbers comparable

‎crates/outloud/src/menuhost.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ impl MenuHost {
314314
// key-down. It only LOOKED restart-only because the pipeline read it
315315
// from a Config copied at startup, so the reload had nowhere to land.
316316
self.runtime.set_sensitivity(self.settings.sensitivity);
317+
// The hot-mic safety net, for the same reason: the pipeline used to
318+
// read it from the startup Config copy, so shortening the timeout
319+
// after noticing a latched microphone did nothing until restart.
320+
self.runtime
321+
.set_hot_mic_timeout_ms(self.settings.silence_timeout_ms.max(1_000) as u64);
317322
// Force a rebuild on the next publish.
318323
self.model = None;
319324
}

‎crates/outloud/src/pipeline.rs‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ pub struct Config {
103103
/// `None` for hosts with no live config (the `--once` measurement path),
104104
/// which then use the flat `sensitivity` field unchanged.
105105
pub live_sensitivity: Option<std::sync::Arc<dyn Fn() -> u8 + Send + Sync>>,
106+
/// Live `silence-timeout-ms`, when the host has one to offer.
107+
///
108+
/// Same freeze as `live_sensitivity`, and a worse consequence:
109+
/// `hot_mic_timeout_ms` below is a snapshot, so shortening the safety
110+
/// net after noticing a latched microphone did nothing until restart --
111+
/// verified on hardware. The setting that bounds how long we may listen
112+
/// must not require restarting the listener.
113+
///
114+
/// `None` for hosts with no live config (the `--once` measurement path),
115+
/// which then use the flat `hot_mic_timeout_ms` field unchanged.
116+
pub live_hot_mic_timeout_ms: Option<std::sync::Arc<dyn Fn() -> u64 + Send + Sync>>,
106117
/// The merged vocabulary for `vocabulary.sets`, when any are active.
107118
///
108119
/// `None` skips the correction pass entirely, which is the common path:
@@ -144,6 +155,7 @@ impl Default for Config {
144155
// The schema default, kept in one place.
145156
sensitivity: 50,
146157
hot_mic_timeout_ms: 60_000,
158+
live_hot_mic_timeout_ms: None,
147159
// Off unless the user asks: the default must be the honest
148160
// indicator, not the faster one.
149161
warm_hold_ms: 0,
@@ -295,12 +307,12 @@ pub async fn run(
295307
// here regardless of how capture was started.
296308
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(
297309
capture_opened_at
298-
.map(|t| t + std::time::Duration::from_millis(cfg.hot_mic_timeout_ms))
310+
.map(|t| t + std::time::Duration::from_millis(current_hot_mic_timeout_ms(&cfg)))
299311
.unwrap_or_else(Instant::now),
300312
)), if capture_opened_at.is_some() => {
301313
eprintln!(
302314
"outloud: capture ran for {}s without ending; closing the microphone",
303-
cfg.hot_mic_timeout_ms / 1000
315+
current_hot_mic_timeout_ms(&cfg) / 1000
304316
);
305317
// Commit rather than discard: the user spoke, and throwing
306318
// their words away to fix a stuck mic trades one silent
@@ -1209,6 +1221,15 @@ type Segmenter = audio::segment::SpeechSegmenter<audio::vad::EnergyVad>;
12091221
/// Prefers the host's live value so a config reload takes effect at the next
12101222
/// key-down rather than the next launch, and falls back to the startup
12111223
/// snapshot for hosts that have no live config.
1224+
/// The hot-mic timeout in force right now: the live view when the host has
1225+
/// one, else the value copied at startup.
1226+
fn current_hot_mic_timeout_ms(cfg: &Config) -> u64 {
1227+
cfg.live_hot_mic_timeout_ms
1228+
.as_ref()
1229+
.map(|f| f())
1230+
.unwrap_or(cfg.hot_mic_timeout_ms)
1231+
}
1232+
12121233
fn current_sensitivity(cfg: &Config) -> u8 {
12131234
let v = cfg
12141235
.live_sensitivity
@@ -1595,6 +1616,39 @@ mod tests {
15951616
);
15961617
}
15971618

1619+
/// The pipeline must read the hot-mic timeout through the live view.
1620+
///
1621+
/// Same shape as the sensitivity test above, and the same bug: the
1622+
/// value was published to a runtime the pipeline never consulted, so
1623+
/// the generated config's promise that edits apply live was false for
1624+
/// the one setting that bounds how long the microphone may stay open.
1625+
/// Verified on hardware before the fix: editing silence-timeout-ms and
1626+
/// latching capture left the old timeout in force until restart.
1627+
#[test]
1628+
fn live_hot_mic_timeout_overrides_the_startup_snapshot() {
1629+
let snapshot_only = Config {
1630+
hot_mic_timeout_ms: 60_000,
1631+
live_hot_mic_timeout_ms: None,
1632+
..Default::default()
1633+
};
1634+
assert_eq!(
1635+
current_hot_mic_timeout_ms(&snapshot_only),
1636+
60_000,
1637+
"with no live view, the startup value stands"
1638+
);
1639+
1640+
let live = Config {
1641+
hot_mic_timeout_ms: 60_000,
1642+
live_hot_mic_timeout_ms: Some(std::sync::Arc::new(|| 5_000)),
1643+
..Default::default()
1644+
};
1645+
assert_eq!(
1646+
current_hot_mic_timeout_ms(&live),
1647+
5_000,
1648+
"a reload must beat the value copied at startup"
1649+
);
1650+
}
1651+
15981652
/// A silent utterance must still count toward `once_after`.
15991653
///
16001654
/// `once_after` was first implemented by counting REPORTS. Silence

‎crates/outloud/src/runtime.rs‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//! [`crate::state::StatusShared`] already works: locks held for nanoseconds,
1414
//! and the reader never blocks the pipeline.
1515
16-
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
16+
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
1717
use std::sync::{Arc, Mutex};
1818

1919
/// What the daemon actually bound and opened.
@@ -72,6 +72,18 @@ pub struct RuntimeShared {
7272
/// same "settings the process can adopt without a restart" channel the
7373
/// Pause switch uses.
7474
sensitivity: Arc<AtomicU8>,
75+
/// `silence-timeout-ms`, as an atomic for exactly the same reason as
76+
/// `sensitivity` above: the pipeline gets `Config` by value at startup,
77+
/// so a value read from that copy is frozen for the life of the process.
78+
///
79+
/// This one is worth more than convenience. It is the safety net that
80+
/// force-closes the microphone when a tap-to-latch capture is never
81+
/// ended, and the generated config header promises edits apply live;
82+
/// verified on hardware that they did not, so shortening the timeout
83+
/// after noticing a hot microphone did nothing until a restart. The
84+
/// setting that limits how long we can be listening is the last one that
85+
/// should require restarting the thing that is listening.
86+
hot_mic_timeout_ms: Arc<AtomicU64>,
7587
}
7688

7789
impl Default for RuntimeShared {
@@ -92,6 +104,9 @@ impl RuntimeShared {
92104
// Matches `Config::default().sensitivity`. Overridden at load,
93105
// and again on every reload.
94106
sensitivity: Arc::new(AtomicU8::new(50)),
107+
// Matches the schema default for `silence-timeout-ms`.
108+
// Overridden at load, and again on every reload.
109+
hot_mic_timeout_ms: Arc::new(AtomicU64::new(60_000)),
95110
}
96111
}
97112

@@ -140,6 +155,22 @@ impl RuntimeShared {
140155
self.sensitivity.load(Ordering::Relaxed)
141156
}
142157

158+
/// Publish `silence-timeout-ms` so a reload reaches the running
159+
/// pipeline. Clamped to the schema's documented range: a zero would
160+
/// mean "close the microphone immediately", i.e. dictation that can
161+
/// never record anything, and an unbounded value would defeat the
162+
/// safety net entirely.
163+
pub fn set_hot_mic_timeout_ms(&self, ms: u64) {
164+
self.hot_mic_timeout_ms
165+
.store(ms.clamp(1_000, 600_000), Ordering::Relaxed);
166+
}
167+
168+
/// How long capture may run before the pipeline force-commits and
169+
/// closes the microphone, as of right now.
170+
pub fn hot_mic_timeout_ms(&self) -> u64 {
171+
self.hot_mic_timeout_ms.load(Ordering::Relaxed)
172+
}
173+
143174
/// Record a working capture device. Also clears the blocked flag: a
144175
/// stream that came up is the definitive answer to "can we record", and
145176
/// leaving a stale warning row in the menu after recovery would train

0 commit comments

Comments
 (0)