@juicesharp/rpiv-voice 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to `@juicesharp/rpiv-voice` are documented here.
5
5
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.8.2] - 2026-05-17
9
+
10
+ ## [1.8.1] - 2026-05-17
11
+
12
+ ### Fixed
13
+ - Voice settings now persist to disk before applying in memory, preventing contradictory success/failure notifications on write failure.
14
+
8
15
  ## [1.8.0] - 2026-05-16
9
16
 
10
17
  ## [1.7.0] - 2026-05-15
@@ -1,25 +1,20 @@
1
1
  /**
2
- * voice-config — best-effort persistence of optional rpiv-voice settings
3
- * (custom model path, named mic device) at `~/.config/rpiv-voice/voice.json`.
2
+ * voice-config — persistence of optional rpiv-voice settings at
3
+ * `~/.config/rpiv-voice/voice.json`.
4
4
  *
5
- * Both load and save are crash-resistant: malformed JSON or filesystem errors
6
- * resolve to "no config". The file is created with 0600 perms because it may
7
- * one day hold device IDs that the user doesn't want world-readable.
5
+ * Load is crash-resistant: malformed JSON or missing file resolves to an
6
+ * empty config (warning emitted via `rpiv-config.loadJsonConfig`).
7
+ *
8
+ * Save returns a `boolean`; the caller (voice-session shell) notifies the
9
+ * user on failure so the UI never lies about persistence ("saved" while the
10
+ * disk write actually failed). chmod to 0600 is best-effort and never gates
11
+ * the return — see `rpiv-config.saveJsonConfig` for the full contract.
8
12
  */
9
13
 
10
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { dirname, join } from "node:path";
14
+ import { configPath, loadJsonConfig, saveJsonConfig } from "@juicesharp/rpiv-config";
13
15
 
14
16
  // ── Filesystem layout ────────────────────────────────────────────────────────
15
- const CONFIG_DIR_NAME = "rpiv-voice";
16
- const CONFIG_FILE_NAME = "voice.json";
17
- const CONFIG_DIR = join(homedir(), ".config", CONFIG_DIR_NAME);
18
- const CONFIG_PATH = join(CONFIG_DIR, CONFIG_FILE_NAME);
19
-
20
- // ── Permissions & formatting ─────────────────────────────────────────────────
21
- const CONFIG_FILE_MODE = 0o600;
22
- const JSON_INDENT = 2;
17
+ const CONFIG_PATH = configPath("rpiv-voice", "voice.json");
23
18
 
24
19
  // ── Module-level singleton key (cleared by test/setup beforeEach) ────────────
25
20
  const VOICE_STATE_KEY = Symbol.for("rpiv-voice");
@@ -32,9 +27,7 @@ export interface VoiceConfig {
32
27
  /**
33
28
  * The hallucination filter defaults to ENABLED. We only persist the off-state
34
29
  * to keep voice.json minimal, which means "field absent" must be read as
35
- * "enabled". Centralizing this rule here keeps the three readers in sync —
36
- * persisted config, pipeline runtime options, and the in-flight settings
37
- * draft all decode the absence the same way.
30
+ * "enabled".
38
31
  */
39
32
  export function isHallucinationFilterEnabled(config: { hallucinationFilterEnabled?: boolean }): boolean {
40
33
  return config.hallucinationFilterEnabled !== false;
@@ -42,36 +35,18 @@ export function isHallucinationFilterEnabled(config: { hallucinationFilterEnable
42
35
 
43
36
  /**
44
37
  * The equalizer defaults to DISABLED. Mirror of the hallucination-filter
45
- * decoding rule but with the inverted polarity: only the on-state is
46
- * persisted, "field absent" reads as "disabled".
38
+ * decoding rule but with the inverted polarity.
47
39
  */
48
40
  export function isEqualizerEnabled(config: { equalizerEnabled?: boolean }): boolean {
49
41
  return config.equalizerEnabled === true;
50
42
  }
51
43
 
52
44
  export function loadVoiceConfig(): VoiceConfig {
53
- if (!existsSync(CONFIG_PATH)) return {};
54
- try {
55
- const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as unknown;
56
- if (parsed === null || typeof parsed !== "object") return {};
57
- return parsed as VoiceConfig;
58
- } catch {
59
- return {};
60
- }
45
+ return loadJsonConfig<VoiceConfig>(CONFIG_PATH);
61
46
  }
62
47
 
63
- export function saveVoiceConfig(config: VoiceConfig): void {
64
- try {
65
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
66
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, JSON_INDENT)}\n`, "utf-8");
67
- } catch {
68
- // best-effort
69
- }
70
- try {
71
- chmodSync(CONFIG_PATH, CONFIG_FILE_MODE);
72
- } catch {
73
- // best-effort — perms are advisory; the user's umask still wins.
74
- }
48
+ export function saveVoiceConfig(config: VoiceConfig): boolean {
49
+ return saveJsonConfig(CONFIG_PATH, config);
75
50
  }
76
51
 
77
52
  export function __resetState(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-voice",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "private": false,
5
5
  "description": "Pi extension. Voice dictation via /voice — local on-device STT with sherpa-onnx Whisper (base multilingual int8), microphone capture via decibri.",
6
6
  "keywords": [
@@ -78,6 +78,7 @@
78
78
  ]
79
79
  },
80
80
  "dependencies": {
81
+ "@juicesharp/rpiv-config": "^1.8.2",
81
82
  "sherpa-onnx-node": "^1.13.0",
82
83
  "decibri": "^3.4.0"
83
84
  },
@@ -16,7 +16,7 @@ export type Effect =
16
16
  | { kind: "stop_mic" }
17
17
  | { kind: "set_pipeline_paused"; paused: boolean }
18
18
  | { kind: "set_hallucination_filter"; enabled: boolean }
19
- | { kind: "save_config"; config: VoiceConfig }
19
+ | { kind: "save_config"; config: VoiceConfig; successMessage?: string }
20
20
  | { kind: "done"; result: VoiceResult };
21
21
 
22
22
  export interface VoiceResult {
@@ -145,13 +145,21 @@ function stepFocus(current: SettingsFieldKey, delta: 1 | -1): SettingsFieldKey {
145
145
  return order[next] ?? current;
146
146
  }
147
147
 
148
+ // Success notify is attached to the save_config effect (rather than a separate
149
+ // notify effect) so the imperative shell can fire it ONLY when persistence
150
+ // succeeds. A plain two-effect list ran both notifies unconditionally on
151
+ // failure because `return` inside runEffect()'s switch case exits the method,
152
+ // not the outer effect loop — review I1 caught this.
148
153
  const settingsSave: Handler<"settings_save"> = (state, _action, _ctx) => {
149
154
  const config = configFromDraft(state.settingsDraft);
150
155
  return {
151
156
  state,
152
157
  effects: [
153
- { kind: "save_config", config },
154
- { kind: "notify", level: "info", message: t("notify.settings_saved", "Voice settings saved") },
158
+ {
159
+ kind: "save_config",
160
+ config,
161
+ successMessage: t("notify.settings_saved", "Voice settings saved"),
162
+ },
155
163
  ],
156
164
  };
157
165
  };
@@ -11,6 +11,7 @@ import { TranscriptView } from "../view/components/transcript-view.js";
11
11
  import { OverlayView } from "../view/overlay-view.js";
12
12
  import { VoiceOverlayPropsAdapter } from "../view/props-adapter.js";
13
13
  import { DictationScreenStrategy, SettingsScreenStrategy } from "../view/screen-content-strategy.js";
14
+ import { t } from "./i18n-bridge.js";
14
15
  import { routeKey, type VoiceAction } from "./key-router.js";
15
16
  import {
16
17
  selectEqualizerFieldProps,
@@ -158,7 +159,20 @@ export class VoiceSession {
158
159
  this.deps.setHallucinationFilterEnabled(effect.enabled);
159
160
  return;
160
161
  case "save_config":
161
- saveVoiceConfig(effect.config);
162
+ if (saveVoiceConfig(effect.config)) {
163
+ // Success notify only fires if the reducer asked for one (Ctrl-S
164
+ // path); the implicit close_settings save omits the message and
165
+ // stays silent. Conditional fire-on-success keeps the user out
166
+ // of the contradictory "Failed … / Saved" state the review caught.
167
+ if (effect.successMessage) {
168
+ this.deps.notify(effect.successMessage, "info");
169
+ }
170
+ } else {
171
+ this.deps.notify(
172
+ t("notify.settings_save_failed", "Failed to save voice settings — change not persisted"),
173
+ "error",
174
+ );
175
+ }
162
176
  return;
163
177
  case "done":
164
178
  this.done(effect.result);