friend-assistant 0.1.43 → 0.1.44

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/README.md CHANGED
@@ -138,7 +138,7 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
138
138
  | `ollama.url`, `ollama.model`, `ollama.vision`, `ollama.contextTokens` | The local model: server address, model name, whether it can see pictures, and how much it remembers at once. |
139
139
  | `openai.url`, `openai.model`, `openai.apiKey`, `openai.vision` | An OpenAI-compatible server: its address (ending in `/v1`), the model name it knows, its API key (or empty), and whether the model can see pictures. For OmniRoute (https://github.com/diegosouzapw/OmniRoute) run `npm install -g omniroute` and `omniroute`, then `friend config set provider openai` (the defaults are OmniRoute's: `http://localhost:20128/v1` and model `auto`). Also works with LM Studio, LiteLLM, vLLM and OpenRouter. |
140
140
  | `voice.hindiVoice`, `voice.marathiVoice` | The voices that read Hindi and Marathi (Devanagari) text: `hi_IN-priyamvada-medium`, `hi_IN-pratham-medium`, `hi_IN-rohan-medium`, `mr_IN-google-medium`. Empty makes the main voice read that language. See "Hindi, Marathi and English together". |
141
- | `voice.enabled`, `voice.sttModel`, `voice.ttsVoice`, `voice.language`, `voice.speakTyped`, `voice.accept`, `voice.challenge` | Speech on or off, the listening model, the speaking voice, a forced language, speaking typed replies, and the two thresholds for recognising your voice. |
141
+ | `voice.enabled`, `voice.sttModel`, `voice.ttsVoice`, `voice.language`, `voice.speakTyped`, `voice.duck`, `voice.duckPercent`, `voice.accept`, `voice.challenge` | Speech on or off, the listening model, the speaking voice, a forced language, speaking typed replies, turning music and video down while you talk and while Friend speaks (and how far, in percent), and the two thresholds for recognising your voice. |
142
142
  | `bind`, `port` | Where Friend listens (easier: `friend lan`). |
143
143
  | `https` | `true` serves a secure `https://` address with a certificate Friend makes for itself, so a phone may use the microphone (easier: `friend lan https on`). |
144
144
  | `publicUrl`, `allowedOrigins`, `allowedHosts`, `trustProxy` | For running behind your own domain or reverse proxy (see "Using your own domain later"). |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.43",
4
- "builtAt": "2026-10-13T05:55:13.217Z"
3
+ "version": "0.1.44",
4
+ "builtAt": "2026-10-13T06:25:53.644Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -312,6 +312,8 @@ var ServerMsg = z3.discriminatedUnion("type", [
312
312
  data: z3.string()
313
313
  }),
314
314
  z3.object({ type: z3.literal("audio.stop") }),
315
+ /** How loud music and video should be while the owner talks or Friend speaks: 1 is as it is, 0.25 a quarter. */
316
+ z3.object({ type: z3.literal("audio.duck"), level: z3.number().min(0).max(1) }),
315
317
  /** The owner approved a look through the camera: take one picture and upload it. */
316
318
  z3.object({ type: z3.literal("camera.request"), id: z3.string() }),
317
319
  /** Stop: the request was answered elsewhere, timed out, or the emergency lock fired. */
@@ -335,6 +337,8 @@ var ClientMsg = z3.discriminatedUnion("type", [
335
337
  z3.object({ type: z3.literal("stage.event"), objectId: z3.string(), event: z3.string().max(64) }),
336
338
  ApprovalRespond,
337
339
  z3.object({ type: z3.literal("voice.interrupt") }),
340
+ /** This screen is listening to the owner, or Friend is speaking on it (true), or neither (false). */
341
+ z3.object({ type: z3.literal("audio.activity"), active: z3.boolean() }),
338
342
  z3.object({ type: z3.literal("voice.confirm"), id: z3.string(), pin: z3.string().max(32) }),
339
343
  z3.object({ type: z3.literal("voice.cancel"), id: z3.string() }),
340
344
  z3.object({ type: z3.literal("camera.denied"), id: z3.string(), reason: z3.enum(["off", "no-camera", "permission", "busy"]) }),
@@ -3352,12 +3356,16 @@ var DEFAULT_VOICE = {
3352
3356
  accept: 0.72,
3353
3357
  challenge: 0.58,
3354
3358
  hindiVoice: DEFAULT_HINDI_VOICE,
3355
- marathiVoice: DEFAULT_MARATHI_VOICE
3359
+ marathiVoice: DEFAULT_MARATHI_VOICE,
3360
+ duck: true,
3361
+ duckPercent: 25
3356
3362
  };
3357
3363
  function sanitizeVoice(v) {
3358
3364
  const ok = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
3359
- if (ok(v.accept) && ok(v.challenge) && v.accept > v.challenge) return v;
3360
- return { ...v, accept: DEFAULT_VOICE.accept, challenge: DEFAULT_VOICE.challenge };
3365
+ const percent2 = typeof v.duckPercent === "number" && Number.isFinite(v.duckPercent) ? Math.max(0, Math.min(100, Math.round(v.duckPercent))) : DEFAULT_VOICE.duckPercent;
3366
+ const base = { ...v, duck: typeof v.duck === "boolean" ? v.duck : DEFAULT_VOICE.duck, duckPercent: percent2 };
3367
+ if (ok(v.accept) && ok(v.challenge) && v.accept > v.challenge) return base;
3368
+ return { ...base, accept: DEFAULT_VOICE.accept, challenge: DEFAULT_VOICE.challenge };
3361
3369
  }
3362
3370
  var DEFAULT_DESTRUCTIVE_PATTERNS = [
3363
3371
  String.raw`\brm\s+(-[a-z]*r|-[a-z]*f)`,
@@ -5292,8 +5300,8 @@ var Mpv = class {
5292
5300
  seek(seconds) {
5293
5301
  return this.cmd(["seek", seconds, "absolute"]);
5294
5302
  }
5295
- volume(percent) {
5296
- return this.cmd(["set_property", "volume", percent]);
5303
+ volume(percent2) {
5304
+ return this.cmd(["set_property", "volume", percent2]);
5297
5305
  }
5298
5306
  mute(on) {
5299
5307
  return this.cmd(["set_property", "mute", on]);
@@ -5384,8 +5392,8 @@ var VideoHost = class {
5384
5392
  seek(seconds) {
5385
5393
  return this.mpv.seek(seconds);
5386
5394
  }
5387
- volume(percent) {
5388
- return this.mpv.volume(percent);
5395
+ volume(percent2) {
5396
+ return this.mpv.volume(percent2);
5389
5397
  }
5390
5398
  stop() {
5391
5399
  return this.mpv.stop();
@@ -5989,10 +5997,10 @@ var Provisioner = class {
5989
5997
  const list2 = await this.text(plan.sums.url);
5990
5998
  const want = checksumFor(list2, plan.sums.name);
5991
5999
  if (!want) return fail(`Friend could not check the ${name} download: no published checksum was found for it.`);
5992
- let got = await this.file(plan.url, part, (percent) => this.set(name, { phase: "downloading", percent }));
6000
+ let got = await this.file(plan.url, part, (percent2) => this.set(name, { phase: "downloading", percent: percent2 }));
5993
6001
  if (got.error === "failed") {
5994
6002
  rmSync4(part, { force: true });
5995
- got = await this.file(plan.url, part, (percent) => this.set(name, { phase: "downloading", percent }));
6003
+ got = await this.file(plan.url, part, (percent2) => this.set(name, { phase: "downloading", percent: percent2 }));
5996
6004
  }
5997
6005
  if (got.error) {
5998
6006
  this.d.onError?.(name, got.error === "big" ? "too big" : got.why ?? "download failed");
@@ -6083,10 +6091,10 @@ var Provisioner = class {
6083
6091
  }
6084
6092
  hash.update(value);
6085
6093
  if (!out.write(value)) await new Promise((r) => out.once("drain", () => r()));
6086
- const percent = total > 0 ? Math.min(99, Math.floor(received / total * 100)) : null;
6087
- if (percent !== lastPercent) {
6088
- lastPercent = percent ?? -1;
6089
- progress(percent);
6094
+ const percent2 = total > 0 ? Math.min(99, Math.floor(received / total * 100)) : null;
6095
+ if (percent2 !== lastPercent) {
6096
+ lastPercent = percent2 ?? -1;
6097
+ progress(percent2);
6090
6098
  }
6091
6099
  }
6092
6100
  await new Promise((resolve13, reject) => {
@@ -6346,6 +6354,10 @@ var VideoHub = class {
6346
6354
  tokens = /* @__PURE__ */ new Map();
6347
6355
  streams = [];
6348
6356
  cached = null;
6357
+ /** The volume the owner chose for the PC's player (mpv keeps it from one video to the next). */
6358
+ hostVolume = 100;
6359
+ /** How loud it is while someone talks: 1 is the owner's volume as it is. */
6360
+ duckLevel = 1;
6349
6361
  now = null;
6350
6362
  tickets = /* @__PURE__ */ new Map();
6351
6363
  clock() {
@@ -6490,6 +6502,8 @@ var VideoHub = class {
6490
6502
  const opened = await this.d.host.open(info.webpage || args.url || "", { start: start2, maxHeight: cfg.maxHeight });
6491
6503
  if (!opened.ok) return { ok: false, text: opened.error };
6492
6504
  this.now = { output, title: info.title, uploader: info.uploader, info, via: "mpv" };
6505
+ if (this.duckLevel < 1) await this.d.host.volume(this.hostLevel()).catch(() => {
6506
+ });
6493
6507
  return { ok: true, text: `Playing ${what} in a window on this PC.` };
6494
6508
  }
6495
6509
  if (browserWindow) {
@@ -6534,6 +6548,17 @@ var VideoHub = class {
6534
6548
  });
6535
6549
  this.now = null;
6536
6550
  }
6551
+ hostLevel() {
6552
+ return Math.round(this.hostVolume * this.duckLevel);
6553
+ }
6554
+ /** Turns the video in the PC's own player down (0.25 is a quarter) while someone talks, and back up (1) after. A video on the screen is turned down by the page. */
6555
+ async duck(level) {
6556
+ this.duckLevel = Math.max(0, Math.min(1, level));
6557
+ const now = this.now;
6558
+ if (now?.output !== "host" || now.via !== "mpv" || !this.d.host) return;
6559
+ await this.d.host.volume(this.hostLevel()).catch(() => {
6560
+ });
6561
+ }
6537
6562
  async control(args) {
6538
6563
  if (args.action === "stop") {
6539
6564
  const wasScreen = this.now?.output === "screen";
@@ -6549,7 +6574,10 @@ var VideoHub = class {
6549
6574
  if (args.action === "pause") await host.pause(true);
6550
6575
  else if (args.action === "resume") await host.pause(false);
6551
6576
  else if (args.action === "seek") await host.seek(Math.max(0, args.seconds ?? 0));
6552
- else await host.volume(Math.max(0, Math.min(100, args.level ?? 50)));
6577
+ else {
6578
+ this.hostVolume = Math.max(0, Math.min(100, args.level ?? 50));
6579
+ await host.volume(this.hostLevel());
6580
+ }
6553
6581
  } catch (e) {
6554
6582
  return { ok: false, text: e instanceof Error ? e.message : "The player did not answer." };
6555
6583
  }
@@ -8907,6 +8935,8 @@ var HostPlayer = class {
8907
8935
  chain = Promise.resolve();
8908
8936
  off;
8909
8937
  disposed = false;
8938
+ /** How loud it is while someone talks: 1 is the owner's volume as it is. */
8939
+ duckLevel = 1;
8910
8940
  now = () => (this.d.clock ?? Date.now)();
8911
8941
  available() {
8912
8942
  const problem = programProblem(this.d.settings().hostPlayer.trim());
@@ -9090,14 +9120,24 @@ var HostPlayer = class {
9090
9120
  if (this.engine === "folder") await this.d.mpv.seek(seconds);
9091
9121
  else if (this.engine === "spotify" && this.deviceId) await this.d.spotify.control(this.deviceId, { action: "seek", ms: Math.round(seconds * 1e3) });
9092
9122
  }
9123
+ /** The owner's volume, turned down while someone is talking. */
9124
+ level() {
9125
+ return Math.round(this.s.volume * this.duckLevel);
9126
+ }
9093
9127
  async applyVolume() {
9094
9128
  if (this.engine === "folder") {
9095
- await this.d.mpv.volume(this.s.volume);
9129
+ await this.d.mpv.volume(this.level());
9096
9130
  await this.d.mpv.mute(this.s.muted);
9097
9131
  } else if (this.engine === "spotify" && this.deviceId) {
9098
- await this.d.spotify.control(this.deviceId, { action: "volume", percent: this.s.muted ? 0 : this.s.volume });
9132
+ await this.d.spotify.control(this.deviceId, { action: "volume", percent: this.s.muted ? 0 : this.level() });
9099
9133
  }
9100
9134
  }
9135
+ /** Turns the sound down (0.25 is a quarter) while the owner talks to Friend or Friend speaks, and back up (1) after. The owner's own volume is not changed. */
9136
+ async duck(level) {
9137
+ this.duckLevel = Math.max(0, Math.min(1, level));
9138
+ if (this.disposed || !this.engine) return;
9139
+ await this.run(() => this.applyVolume());
9140
+ }
9101
9141
  // ---- what the engines tell us ----
9102
9142
  onMpv(e) {
9103
9143
  if (this.disposed) return;
@@ -12956,7 +12996,9 @@ var SettingsPatch = z11.object({
12956
12996
  /** A Piper voice name; empty makes the main voice read Hindi too. */
12957
12997
  hindiVoice: z11.string().trim().max(80).regex(/^[A-Za-z0-9_-]*$/, "A Piper voice name such as hi_IN-priyamvada-medium, or empty"),
12958
12998
  marathiVoice: z11.string().trim().max(80).regex(/^[A-Za-z0-9_-]*$/, "A Piper voice name such as mr_IN-google-medium, or empty"),
12959
- sttModel: z11.string().trim().min(1).max(80).regex(/^[\w./-]+$/)
12999
+ sttModel: z11.string().trim().min(1).max(80).regex(/^[\w./-]+$/),
13000
+ duck: z11.boolean(),
13001
+ duckPercent: z11.number().int().min(0).max(100)
12960
13002
  }).partial().strict()
12961
13003
  }).partial().strict();
12962
13004
  function settingsView(c) {
@@ -13017,7 +13059,9 @@ function settingsView(c) {
13017
13059
  ttsVoice: c.voice.ttsVoice,
13018
13060
  sttModel: c.voice.sttModel,
13019
13061
  hindiVoice: c.voice.hindiVoice,
13020
- marathiVoice: c.voice.marathiVoice
13062
+ marathiVoice: c.voice.marathiVoice,
13063
+ duck: c.voice.duck,
13064
+ duckPercent: c.voice.duckPercent
13021
13065
  }
13022
13066
  };
13023
13067
  }
@@ -13609,6 +13653,42 @@ async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
13609
13653
  return { key: pems.private, cert: pems.cert };
13610
13654
  }
13611
13655
 
13656
+ // ../server/src/audio/duck.ts
13657
+ var FALLBACK_PERCENT = 25;
13658
+ var DuckController = class {
13659
+ constructor(d) {
13660
+ this.d = d;
13661
+ }
13662
+ d;
13663
+ active = /* @__PURE__ */ new Set();
13664
+ current = 1;
13665
+ /** How loud things should be now: 1 as normal. */
13666
+ get level() {
13667
+ return this.current;
13668
+ }
13669
+ set(source, active) {
13670
+ if (active) this.active.add(source);
13671
+ else this.active.delete(source);
13672
+ this.update();
13673
+ }
13674
+ /** A screen went away. */
13675
+ drop(source) {
13676
+ if (this.active.delete(source)) this.update();
13677
+ }
13678
+ /** The owner changed the settings. */
13679
+ refresh() {
13680
+ this.update();
13681
+ }
13682
+ update() {
13683
+ const raw = this.d.percent();
13684
+ const percent2 = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : FALLBACK_PERCENT;
13685
+ const next = this.d.enabled() && this.active.size > 0 ? percent2 / 100 : 1;
13686
+ if (next === this.current) return;
13687
+ this.current = next;
13688
+ this.d.apply(next);
13689
+ }
13690
+ };
13691
+
13612
13692
  // ../server/src/voice/challenge.ts
13613
13693
  import { randomUUID as randomUUID6 } from "node:crypto";
13614
13694
  var Challenges = class {
@@ -14484,6 +14564,7 @@ async function createApp(opts) {
14484
14564
  else swap();
14485
14565
  }
14486
14566
  if (applied.changed.includes("voice.ttsVoice")) void warmVoice(config.voice.ttsVoice, voiceBefore);
14567
+ if (applied.changed.some((k) => k === "voice.duck" || k === "voice.duckPercent")) ducker.refresh();
14487
14568
  if (applied.changed.includes("voice.sttModel")) void warmListening(config.voice.sttModel, sttBefore);
14488
14569
  if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
14489
14570
  if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
@@ -14925,6 +15006,20 @@ async function createApp(opts) {
14925
15006
  return key;
14926
15007
  }
14927
15008
  };
15009
+ const duckIds = /* @__PURE__ */ new WeakMap();
15010
+ let duckSeq = 0;
15011
+ const duckId = (conn) => duckIds.get(conn) ?? (duckIds.set(conn, `screen-${++duckSeq}`), duckIds.get(conn));
15012
+ const ducker = new DuckController({
15013
+ enabled: () => config.voice.duck,
15014
+ percent: () => config.voice.duckPercent,
15015
+ apply: (level) => {
15016
+ broadcast({ type: "audio.duck", level });
15017
+ void hostPlayer.duck(level).catch(() => {
15018
+ });
15019
+ void videoHub.duck(level).catch(() => {
15020
+ });
15021
+ }
15022
+ });
14928
15023
  const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
14929
15024
  const gateway = createGateway({
14930
15025
  ...tls ? { tls } : {},
@@ -15338,11 +15433,13 @@ ${url}`);
15338
15433
  conn.send(statusMsg());
15339
15434
  conn.send(uiMsg());
15340
15435
  conn.send({ type: "music.host", state: hostPlayer.state() });
15436
+ if (ducker.level < 1) conn.send({ type: "audio.duck", level: ducker.level });
15341
15437
  const pending = challenges.current();
15342
15438
  if (pending) conn.send({ type: "voice.challenge", id: pending.id, text: pending.text });
15343
15439
  return () => {
15344
15440
  clients.delete(conn);
15345
15441
  musicHub.detach(conn);
15442
+ ducker.drop(duckId(conn));
15346
15443
  };
15347
15444
  },
15348
15445
  onClientMessage: (_session, msg, conn) => {
@@ -15360,6 +15457,9 @@ ${url}`);
15360
15457
  case "voice.interrupt":
15361
15458
  speaker?.interrupt(broadcast);
15362
15459
  break;
15460
+ case "audio.activity":
15461
+ ducker.set(duckId(conn), msg.active);
15462
+ break;
15363
15463
  case "voice.confirm":
15364
15464
  void confirmChallenge(msg.id, msg.pin, conn).catch((e) => audit.log("system", "voice.confirm_error", { message: e.message }));
15365
15465
  break;
@@ -15658,6 +15758,11 @@ var unit2 = (v) => {
15658
15758
  if (v.trim() === "" || !Number.isFinite(n) || n < 0 || n > 1) throw new Error("Expected a number between 0 and 1");
15659
15759
  return n;
15660
15760
  };
15761
+ var percent = (v) => {
15762
+ const n = Number(v);
15763
+ if (v.trim() === "" || !Number.isFinite(n) || n < 0 || n > 100) throw new Error("Expected a number between 0 and 100");
15764
+ return Math.round(n);
15765
+ };
15661
15766
  var httpUrl = (v) => {
15662
15767
  let u;
15663
15768
  try {
@@ -15716,6 +15821,8 @@ var GROUPS = {
15716
15821
  },
15717
15822
  language: (v) => v.trim() === "" ? null : v.trim(),
15718
15823
  speakTyped: bool,
15824
+ duck: bool,
15825
+ duckPercent: percent,
15719
15826
  accept: unit2,
15720
15827
  challenge: unit2
15721
15828
  },
@@ -15780,6 +15887,8 @@ var KEY_HELP = {
15780
15887
  "voice.hindiVoice": "the voice that reads Hindi (Devanagari) text: hi_IN-priyamvada-medium, hi_IN-pratham-medium or hi_IN-rohan-medium; empty uses the main voice",
15781
15888
  "voice.marathiVoice": "the voice that reads Marathi (Devanagari) text: mr_IN-google-medium; empty uses the main voice",
15782
15889
  "voice.speakTyped": "true or false: also speak the replies to typed messages",
15890
+ "voice.duck": "true or false: turn music and video down while you talk to Friend and while it speaks",
15891
+ "voice.duckPercent": "0 to 100: how loud music and video are then, as a percent of how loud they were (25 is a quarter)",
15783
15892
  "voice.accept": "0 to 1: a voice at or above this match is you",
15784
15893
  "voice.challenge": "0 to 1: a voice between this and accept must be confirmed with your PIN",
15785
15894
  "ollama.url": "address of the Ollama server, for example http://127.0.0.1:11434",
@@ -17943,7 +18052,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
17943
18052
  import crossSpawn5 from "cross-spawn";
17944
18053
  var PACKAGE = "friend-assistant";
17945
18054
  var real = {
17946
- current: () => true ? "0.1.43" : "development",
18055
+ current: () => true ? "0.1.44" : "development",
17947
18056
  latest: () => new Promise((resolve13) => {
17948
18057
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
17949
18058
  let out = "";
@@ -18231,7 +18340,7 @@ async function run(argv, io2) {
18231
18340
  case "version":
18232
18341
  case "--version":
18233
18342
  case "-v":
18234
- io2.out(true ? "0.1.43" : "development");
18343
+ io2.out(true ? "0.1.44" : "development");
18235
18344
  return 0;
18236
18345
  case "lan":
18237
18346
  return await lanCommand(rest, io2);