realtime-voice-agents 2.0.1 → 2.2.0
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 +58 -2
- package/dist/{BaseRealtimeProvider-BehPNT1r.d.cts → BaseRealtimeProvider-BL75_HHh.d.cts} +53 -3
- package/dist/{BaseRealtimeProvider-DI4pKtOb.cjs → BaseRealtimeProvider-C2mRn1V_.cjs} +10 -0
- package/dist/{BaseRealtimeProvider-BQigr5mB.mjs → BaseRealtimeProvider-C9C3s8jx.mjs} +10 -0
- package/dist/{BaseRealtimeProvider-ClP8Wx1X.d.mts → BaseRealtimeProvider-CWJ81HIt.d.mts} +53 -3
- package/dist/{GeminiLiveProvider-DvkTgzjG.d.cts → GeminiLiveProvider-Cc1dkxW6.d.cts} +1 -1
- package/dist/{GeminiLiveProvider-x2nyx5aO.d.mts → GeminiLiveProvider-TvY_cQQZ.d.mts} +1 -1
- package/dist/{OpenAICompatibleProvider-Bdtl-UXH.mjs → OpenAICompatibleProvider-D-2OOVBU.mjs} +98 -11
- package/dist/{OpenAICompatibleProvider-NS4cKVQj.cjs → OpenAICompatibleProvider-Mp0Mefbh.cjs} +98 -11
- package/dist/audio.cjs +9 -6
- package/dist/audio.d.cts +51 -1
- package/dist/audio.d.mts +51 -1
- package/dist/audio.mjs +2 -2
- package/dist/gemini.cjs +16 -6
- package/dist/gemini.d.cts +8 -3
- package/dist/gemini.d.mts +8 -3
- package/dist/gemini.mjs +16 -6
- package/dist/index.cjs +356 -16
- package/dist/index.d.cts +211 -2
- package/dist/index.d.mts +211 -2
- package/dist/index.mjs +355 -16
- package/dist/{BackgroundAudioPlayer-iMcivjis.mjs → noise-CJ789zzj.mjs} +94 -1
- package/dist/{BackgroundAudioPlayer-jfRULWKC.cjs → noise-D9jKottW.cjs} +111 -0
- package/dist/openai.cjs +22 -8
- package/dist/openai.d.cts +31 -3
- package/dist/openai.d.mts +31 -3
- package/dist/openai.mjs +22 -8
- package/dist/{session-config-BVLl7-ha.mjs → session-config-CbifLlkV.mjs} +1 -1
- package/dist/{session-config-c8sOw1XL.cjs → session-config-CqJm2Kxz.cjs} +1 -1
- package/dist/testing.cjs +29 -2
- package/dist/testing.d.cts +31 -1
- package/dist/testing.d.mts +31 -1
- package/dist/testing.mjs +29 -2
- package/dist/xai.cjs +23 -9
- package/dist/xai.d.cts +7 -2
- package/dist/xai.d.mts +7 -2
- package/dist/xai.mjs +23 -9
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_rolldown_runtime = require("./rolldown-runtime-VH7oDXx4.cjs");
|
|
3
3
|
const require_events = require("./events-BxDTIKKq.cjs");
|
|
4
|
-
const require_BaseRealtimeProvider = require("./BaseRealtimeProvider-
|
|
4
|
+
const require_BaseRealtimeProvider = require("./BaseRealtimeProvider-C2mRn1V_.cjs");
|
|
5
5
|
const require_InMemorySessionStore = require("./InMemorySessionStore-B0_i-DOU.cjs");
|
|
6
6
|
const require_rest = require("./rest-BvUKut_k.cjs");
|
|
7
7
|
const require_mulaw = require("./mulaw-DLUObjdP.cjs");
|
|
8
|
-
const
|
|
9
|
-
const require_session_config = require("./session-config-
|
|
8
|
+
const require_noise = require("./noise-D9jKottW.cjs");
|
|
9
|
+
const require_session_config = require("./session-config-CqJm2Kxz.cjs");
|
|
10
10
|
let node_fs = require("node:fs");
|
|
11
11
|
let zod = require("zod");
|
|
12
12
|
zod = require_rolldown_runtime.__toESM(zod, 1);
|
|
@@ -499,6 +499,190 @@ var PlaybackTracker = class {
|
|
|
499
499
|
}
|
|
500
500
|
};
|
|
501
501
|
//#endregion
|
|
502
|
+
//#region src/vad/NoiseAdaptiveVadController.ts
|
|
503
|
+
/**
|
|
504
|
+
* Noise-adaptive VAD policy: watches inbound caller audio through a
|
|
505
|
+
* NoiseFloorEstimator and, on sustained pervasive noise, proposes a bounded
|
|
506
|
+
* turn-detection escalation (server VAD threshold up stepwise; semantic VAD
|
|
507
|
+
* and sensitivity-only providers get suggestion events instead).
|
|
508
|
+
*
|
|
509
|
+
* Modeled on InterruptionController: pure decision objects, zero timers —
|
|
510
|
+
* every clock is measured in milliseconds of inbound audio, so behavior is
|
|
511
|
+
* deterministic in tests and matches wall clock on a live call.
|
|
512
|
+
*
|
|
513
|
+
* Lifecycle: onInboundFrame() PROPOSES without mutating ladder state — the
|
|
514
|
+
* identical proposal recurs until the caller settles it:
|
|
515
|
+
* - commitApplied(): the provider ACKNOWLEDGED the change (auto mode). Only
|
|
516
|
+
* this and rebase() move the effective config — an emitted-but-unapplied
|
|
517
|
+
* suggestion is never treated as reality.
|
|
518
|
+
* - markSuggested(): the suggestion was surfaced (suggest mode, or a rung
|
|
519
|
+
* that is never auto-applied). Latched until rebase() so it fires once.
|
|
520
|
+
* - defer(): the apply failed or timed out — retry after cooldownMs.
|
|
521
|
+
* - rebase(): the app changed VAD via session.updateVad() — new effective
|
|
522
|
+
* base, fresh ladder. rebase(null) disables the controller entirely (turn
|
|
523
|
+
* detection off — noise adaptation must never re-enable it on its own).
|
|
524
|
+
*/
|
|
525
|
+
function round2(value) {
|
|
526
|
+
return Math.round(value * 100) / 100;
|
|
527
|
+
}
|
|
528
|
+
var NoiseAdaptiveVadController = class {
|
|
529
|
+
windowMs;
|
|
530
|
+
noiseFloorDb;
|
|
531
|
+
sustainMs;
|
|
532
|
+
cooldownMs;
|
|
533
|
+
maxSteps;
|
|
534
|
+
thresholdStep;
|
|
535
|
+
maxThreshold;
|
|
536
|
+
profile;
|
|
537
|
+
estimator;
|
|
538
|
+
/** Current EFFECTIVE config — moved only by commitApplied()/rebase(). */
|
|
539
|
+
effective;
|
|
540
|
+
disabled;
|
|
541
|
+
stepsTaken = 0;
|
|
542
|
+
suggestionLatched = false;
|
|
543
|
+
sustainedMs = 0;
|
|
544
|
+
/** Infinity = no step yet, so the first proposal is never cooldown-blocked. */
|
|
545
|
+
msSinceLastStep = Number.POSITIVE_INFINITY;
|
|
546
|
+
elapsedMs = 0;
|
|
547
|
+
analyzedMs = 0;
|
|
548
|
+
/** Memoized next rung; `undefined` = dirty, `null` = ladder exhausted. */
|
|
549
|
+
cachedNext = void 0;
|
|
550
|
+
constructor(options, effectiveVad, profile) {
|
|
551
|
+
this.windowMs = options.windowMs ?? 5e3;
|
|
552
|
+
this.noiseFloorDb = options.noiseFloorDb ?? -45;
|
|
553
|
+
this.sustainMs = options.sustainMs ?? 3e3;
|
|
554
|
+
this.cooldownMs = options.cooldownMs ?? 15e3;
|
|
555
|
+
this.maxSteps = options.maxSteps ?? 1;
|
|
556
|
+
this.thresholdStep = options.thresholdStep ?? .1;
|
|
557
|
+
this.maxThreshold = options.maxThreshold ?? .9;
|
|
558
|
+
this.profile = profile;
|
|
559
|
+
this.estimator = new require_noise.NoiseFloorEstimator({ windowMs: this.windowMs });
|
|
560
|
+
this.disabled = effectiveVad === null;
|
|
561
|
+
this.effective = effectiveVad ?? { type: "server" };
|
|
562
|
+
}
|
|
563
|
+
/** No escalation room at all (disabled, or the ladder has no next rung). */
|
|
564
|
+
get exhausted() {
|
|
565
|
+
return this.disabled || this.nextRung() === null;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Feed one inbound Twilio frame (base64 μ-law). `excluded` frames (user
|
|
569
|
+
* speech, agent playback, pregreeting) advance the cooldown clock but are
|
|
570
|
+
* kept out of the floor estimate — they freeze, not reset, the sustain
|
|
571
|
+
* counter. Pure w.r.t. ladder state: the same proposal recurs until
|
|
572
|
+
* commitApplied/markSuggested/defer/rebase.
|
|
573
|
+
*/
|
|
574
|
+
onInboundFrame(base64Mulaw, excluded) {
|
|
575
|
+
if (this.disabled || this.suggestionLatched) return null;
|
|
576
|
+
const next = this.nextRung();
|
|
577
|
+
if (!next) return null;
|
|
578
|
+
const bytes = Buffer.from(base64Mulaw, "base64");
|
|
579
|
+
if (bytes.length === 0) return null;
|
|
580
|
+
const frameMs = bytes.length / 8;
|
|
581
|
+
this.elapsedMs += frameMs;
|
|
582
|
+
this.msSinceLastStep += frameMs;
|
|
583
|
+
if (excluded) return null;
|
|
584
|
+
this.estimator.addAudio(bytes);
|
|
585
|
+
this.analyzedMs += frameMs;
|
|
586
|
+
if (!this.estimator.isWarm) return null;
|
|
587
|
+
const floor = this.estimator.floorDb();
|
|
588
|
+
if (floor >= this.noiseFloorDb) this.sustainedMs += frameMs;
|
|
589
|
+
else this.sustainedMs = 0;
|
|
590
|
+
if (this.sustainedMs < this.sustainMs) return null;
|
|
591
|
+
if (this.msSinceLastStep < this.cooldownMs) return null;
|
|
592
|
+
return {
|
|
593
|
+
previous: { ...this.effective },
|
|
594
|
+
suggested: next.suggested,
|
|
595
|
+
step: this.stepsTaken + 1,
|
|
596
|
+
maxSteps: this.maxSteps,
|
|
597
|
+
autoApplicable: next.autoApplicable,
|
|
598
|
+
metrics: {
|
|
599
|
+
noiseFloorDb: floor,
|
|
600
|
+
windowMs: this.windowMs,
|
|
601
|
+
sustainedMs: this.sustainedMs,
|
|
602
|
+
analyzedMs: this.analyzedMs,
|
|
603
|
+
elapsedMs: this.elapsedMs
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/** The proposal was applied AND acknowledged: it is the new effective truth. */
|
|
608
|
+
commitApplied(adjustment) {
|
|
609
|
+
this.effective = { ...adjustment.suggested };
|
|
610
|
+
this.stepsTaken++;
|
|
611
|
+
this.sustainedMs = 0;
|
|
612
|
+
this.msSinceLastStep = 0;
|
|
613
|
+
this.cachedNext = void 0;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The suggestion was emitted without being applied: latch so it fires once
|
|
617
|
+
* per effective state. The effective config is deliberately NOT moved — an
|
|
618
|
+
* unapplied suggestion is not reality.
|
|
619
|
+
*/
|
|
620
|
+
markSuggested(_adjustment) {
|
|
621
|
+
this.suggestionLatched = true;
|
|
622
|
+
}
|
|
623
|
+
/** The apply failed/timed out/was superseded: retry after cooldownMs. */
|
|
624
|
+
defer() {
|
|
625
|
+
this.msSinceLastStep = 0;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* App-driven updateVad(): the declared config is the new effective base —
|
|
629
|
+
* a fresh ladder starts from it (never below it). `null` disables the
|
|
630
|
+
* controller; a later non-null rebase re-enables it. A full cooldown
|
|
631
|
+
* applies before the next adaptive step.
|
|
632
|
+
*/
|
|
633
|
+
rebase(vad) {
|
|
634
|
+
this.disabled = vad === null;
|
|
635
|
+
this.effective = vad ? { ...vad } : { type: "server" };
|
|
636
|
+
this.stepsTaken = 0;
|
|
637
|
+
this.suggestionLatched = false;
|
|
638
|
+
this.sustainedMs = 0;
|
|
639
|
+
this.msSinceLastStep = 0;
|
|
640
|
+
this.cachedNext = void 0;
|
|
641
|
+
}
|
|
642
|
+
nextRung() {
|
|
643
|
+
if (this.cachedNext === void 0) this.cachedNext = this.computeNextRung();
|
|
644
|
+
return this.cachedNext;
|
|
645
|
+
}
|
|
646
|
+
computeNextRung() {
|
|
647
|
+
const effective = this.effective;
|
|
648
|
+
if (effective.type === "semantic") {
|
|
649
|
+
if (effective.eagerness === "low") return null;
|
|
650
|
+
return {
|
|
651
|
+
suggested: {
|
|
652
|
+
...effective,
|
|
653
|
+
eagerness: "low"
|
|
654
|
+
},
|
|
655
|
+
autoApplicable: false
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (this.stepsTaken < this.maxSteps) {
|
|
659
|
+
const baseline = effective.threshold ?? this.profile?.defaultServerThreshold;
|
|
660
|
+
if (baseline !== void 0) {
|
|
661
|
+
const ceiling = Math.min(this.maxThreshold, this.profile?.maxServerThreshold ?? 1);
|
|
662
|
+
const bottom = this.profile?.minServerThreshold ?? 0;
|
|
663
|
+
const target = round2(Math.min(Math.max(baseline + this.thresholdStep, bottom), ceiling));
|
|
664
|
+
if (target > baseline + 1e-9) return {
|
|
665
|
+
suggested: {
|
|
666
|
+
...effective,
|
|
667
|
+
type: "server",
|
|
668
|
+
threshold: target,
|
|
669
|
+
startSensitivity: "low"
|
|
670
|
+
},
|
|
671
|
+
autoApplicable: true
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (effective.startSensitivity !== "low") return {
|
|
676
|
+
suggested: {
|
|
677
|
+
...effective,
|
|
678
|
+
startSensitivity: "low"
|
|
679
|
+
},
|
|
680
|
+
autoApplicable: false
|
|
681
|
+
};
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
//#endregion
|
|
502
686
|
//#region src/providers/base/reconnect.ts
|
|
503
687
|
const DEFAULT_RECONNECT_POLICY = {
|
|
504
688
|
maxAttempts: 5,
|
|
@@ -698,6 +882,12 @@ function createTransferCallTool(options = {}) {
|
|
|
698
882
|
* socket is released exactly once.
|
|
699
883
|
*/
|
|
700
884
|
const MAX_BUFFERED_INBOUND_FRAMES = 250;
|
|
885
|
+
/**
|
|
886
|
+
* Cap on the noise meter's caller-speech exclusion: Gemini never emits a
|
|
887
|
+
* speech-stopped event, and phantom speech starts on a noisy line must not
|
|
888
|
+
* latch the meter off forever.
|
|
889
|
+
*/
|
|
890
|
+
const MAX_SPEECH_EXCLUSION_MS = 15e3;
|
|
701
891
|
var CallSession = class extends require_events.TypedEmitter {
|
|
702
892
|
callSid;
|
|
703
893
|
streamSid;
|
|
@@ -717,6 +907,8 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
717
907
|
runningTools = /* @__PURE__ */ new Map();
|
|
718
908
|
timers = /* @__PURE__ */ new Set();
|
|
719
909
|
provider = null;
|
|
910
|
+
/** Primary + fallbacks, in try-order. Only walked while connecting. */
|
|
911
|
+
providerChain;
|
|
720
912
|
activeAgentValue;
|
|
721
913
|
generating = false;
|
|
722
914
|
currentResponseId = null;
|
|
@@ -754,12 +946,28 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
754
946
|
handoffInProgress = false;
|
|
755
947
|
/** Pre-synthesized greeting playout state. */
|
|
756
948
|
pregreeting = null;
|
|
949
|
+
/** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
|
|
950
|
+
noiseVad = null;
|
|
951
|
+
/**
|
|
952
|
+
* DESIRED turn-detection override — drives reconnect/handoff rebuilds via
|
|
953
|
+
* buildProviderInit. Distinct from the provider's ACKed effective state:
|
|
954
|
+
* manual updateVad() promotes it immediately (user intent survives network
|
|
955
|
+
* failures); adaptive applies promote it only after the provider's ack.
|
|
956
|
+
*/
|
|
957
|
+
vadOverride = void 0;
|
|
958
|
+
/** VAD mutations in flight (manual AND adaptive); proposals drop while > 0. */
|
|
959
|
+
vadOpsInFlight = 0;
|
|
960
|
+
/** Bumped by every updateVad(); a stale revision means an adaptive apply was superseded. */
|
|
961
|
+
vadRevision = 0;
|
|
962
|
+
userSpeechActive = false;
|
|
963
|
+
userSpeechStartedAtMs = 0;
|
|
757
964
|
constructor(deps) {
|
|
758
965
|
super();
|
|
759
966
|
this.deps = deps;
|
|
760
967
|
this.callSid = deps.start.start.callSid;
|
|
761
968
|
this.streamSid = deps.start.start.streamSid ?? deps.start.streamSid;
|
|
762
969
|
this.log = require_BaseRealtimeProvider.childLogger(deps.logger, { callSid: this.callSid });
|
|
970
|
+
this.providerChain = [deps.providerFactory, ...deps.fallbacks ?? []];
|
|
763
971
|
this.activeAgentValue = deps.agent;
|
|
764
972
|
this.context = new SessionContext(deps.options.context);
|
|
765
973
|
this.interruptions = new InterruptionController(deps.options.interruptions);
|
|
@@ -773,7 +981,7 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
773
981
|
this.answered = deps.answeredEarly === true || this.callInfo.direction === "inbound";
|
|
774
982
|
this.agents = collectAgentGraph(deps.agent);
|
|
775
983
|
this.middlewares = deps.middlewares ?? [];
|
|
776
|
-
this.bgAudio = new
|
|
984
|
+
this.bgAudio = new require_noise.BackgroundAudioPlayer({
|
|
777
985
|
sendMedia: (payload) => {
|
|
778
986
|
if (this.stateValue === "active" && this.deps.transport.isOpen) this.deps.transport.sendMedia(payload);
|
|
779
987
|
},
|
|
@@ -798,17 +1006,7 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
798
1006
|
/** Connect the provider and activate the call. Called by the bridge. */
|
|
799
1007
|
async begin() {
|
|
800
1008
|
this.playPreGreeting();
|
|
801
|
-
|
|
802
|
-
this.provider = this.deps.providerFactory({
|
|
803
|
-
logger: this.log,
|
|
804
|
-
callSid: this.callSid
|
|
805
|
-
});
|
|
806
|
-
this.wireProvider(this.provider);
|
|
807
|
-
await this.provider.connect(this.buildProviderInit());
|
|
808
|
-
} catch (error) {
|
|
809
|
-
this.fail(error instanceof Error ? error : new Error(String(error)), "provider-failed");
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
1009
|
+
if (!await this.connectInitialProvider()) return;
|
|
812
1010
|
if (this.stateValue !== "connecting") return;
|
|
813
1011
|
this.stateValue = "active";
|
|
814
1012
|
this.emit("provider.connected");
|
|
@@ -820,6 +1018,10 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
820
1018
|
to: this.callInfo.to,
|
|
821
1019
|
customParameters: this.callInfo.customParameters
|
|
822
1020
|
});
|
|
1021
|
+
if (this.deps.options.noiseAdaptiveVad && this.deps.options.vad !== null) {
|
|
1022
|
+
this.noiseVad = new NoiseAdaptiveVadController(this.deps.options.noiseAdaptiveVad, this.provider.getEffectiveVad(), this.provider.capabilities.vadTuning);
|
|
1023
|
+
if (this.noiseVad.exhausted) this.log.warn("noiseAdaptiveVad enabled but the effective VAD config leaves no escalation room");
|
|
1024
|
+
}
|
|
823
1025
|
if (this.pregreeting) this.provider.sendText(this.pregreeting.text, {
|
|
824
1026
|
role: "assistant",
|
|
825
1027
|
triggerResponse: false
|
|
@@ -830,6 +1032,55 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
830
1032
|
this.saveSnapshot();
|
|
831
1033
|
this.maybeGreet();
|
|
832
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Walk primary + fallbacks until one connects. A provider that fails to
|
|
1037
|
+
* come up is detached BEFORE the chain advances, so a half-open socket's
|
|
1038
|
+
* late events can never reach the session once the next provider owns the
|
|
1039
|
+
* call. Connect-time only by design: once a provider has answered, the
|
|
1040
|
+
* call stays with it. Returns false after failing the call (chain
|
|
1041
|
+
* exhausted) or when the call was torn down while connecting.
|
|
1042
|
+
*/
|
|
1043
|
+
async connectInitialProvider() {
|
|
1044
|
+
let lastFrom = "provider";
|
|
1045
|
+
let lastError = null;
|
|
1046
|
+
for (const factory of this.providerChain) {
|
|
1047
|
+
let provider;
|
|
1048
|
+
try {
|
|
1049
|
+
provider = factory({
|
|
1050
|
+
logger: this.log,
|
|
1051
|
+
callSid: this.callSid
|
|
1052
|
+
});
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
lastError = toError(error);
|
|
1055
|
+
this.log.warn("provider factory threw — trying the next fallback", { error: String(lastError) });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (lastError) this.emit("provider.fallback", {
|
|
1059
|
+
from: lastFrom,
|
|
1060
|
+
to: provider.name,
|
|
1061
|
+
error: lastError
|
|
1062
|
+
});
|
|
1063
|
+
this.provider = provider;
|
|
1064
|
+
this.wireProvider(provider);
|
|
1065
|
+
try {
|
|
1066
|
+
await provider.connect(this.buildProviderInit());
|
|
1067
|
+
return true;
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
provider.removeAllListeners();
|
|
1070
|
+
provider.close().catch(() => {});
|
|
1071
|
+
this.provider = null;
|
|
1072
|
+
if (this.stateValue !== "connecting") return false;
|
|
1073
|
+
lastFrom = provider.name;
|
|
1074
|
+
lastError = toError(error);
|
|
1075
|
+
this.log.warn("provider failed to connect", {
|
|
1076
|
+
provider: provider.name,
|
|
1077
|
+
error: String(lastError)
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
this.fail(lastError ?? /* @__PURE__ */ new Error("provider connect failed"), "provider-failed");
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
833
1084
|
/** The outbound leg was answered (host's Twilio status callback). */
|
|
834
1085
|
notifyAnswered() {
|
|
835
1086
|
if (this.answered) return;
|
|
@@ -842,6 +1093,36 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
842
1093
|
async updateInstructions(instructions) {
|
|
843
1094
|
await this.provider?.updateSession({ instructions });
|
|
844
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Replace the session's turn-detection config mid-call. The value is the
|
|
1098
|
+
* new DESIRED state: it survives reconnects and handoffs, and the
|
|
1099
|
+
* noise-adaptive ladder (when enabled) rebases onto it — future escalation
|
|
1100
|
+
* is relative to this config, never below it. `null` disables turn
|
|
1101
|
+
* detection AND suspends noise adaptation (adaptation never re-enables VAD
|
|
1102
|
+
* on its own); a later non-null call re-enables both.
|
|
1103
|
+
*
|
|
1104
|
+
* Returns true when the live session applied it — provider-acknowledged
|
|
1105
|
+
* when `noiseAdaptiveVad` is configured (serialized updates), best-effort
|
|
1106
|
+
* "sent" otherwise. False when it could not be applied live (no provider,
|
|
1107
|
+
* call ended, no sessionUpdate capability, or the ack timed out — the
|
|
1108
|
+
* resulting reconnect then applies the stored config).
|
|
1109
|
+
*/
|
|
1110
|
+
async updateVad(vad) {
|
|
1111
|
+
this.vadRevision++;
|
|
1112
|
+
this.vadOpsInFlight++;
|
|
1113
|
+
try {
|
|
1114
|
+
this.vadOverride = vad;
|
|
1115
|
+
if (!this.provider || this.stateValue === "ended") return false;
|
|
1116
|
+
if (!this.provider.capabilities.sessionUpdate) {
|
|
1117
|
+
this.log.warn("updateVad: provider has no mid-session update — stored for the next reconnect");
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
return await this.provider.updateSession({ vad }, { awaitAck: true }) === true;
|
|
1121
|
+
} finally {
|
|
1122
|
+
this.noiseVad?.rebase(vad);
|
|
1123
|
+
this.vadOpsInFlight--;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
845
1126
|
/** Manual barge-in: stop the agent mid-sentence. */
|
|
846
1127
|
interrupt() {
|
|
847
1128
|
if (!this.generating && !this.tracker.isPlaybackActive()) return;
|
|
@@ -935,8 +1216,9 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
935
1216
|
return {
|
|
936
1217
|
instructions: this.activeAgentValue.resolveInstructions(this.context) + (this.pregreeting ? `\n\nYou already opened the call by saying: "${this.pregreeting.text}". Do not greet again — continue the conversation from there.` : ""),
|
|
937
1218
|
voice: this.activeAgentValue.voice,
|
|
938
|
-
vad: this.deps.options.vad,
|
|
1219
|
+
vad: this.vadOverride !== void 0 ? this.vadOverride : this.deps.options.vad,
|
|
939
1220
|
bridgeOwnsInterruptions: true,
|
|
1221
|
+
serializedSessionUpdates: this.deps.options.noiseAdaptiveVad !== void 0,
|
|
940
1222
|
tools: [...this.toolset.values()].map((tool) => {
|
|
941
1223
|
const decorated = decorateTool(tool, this.middlewares);
|
|
942
1224
|
return {
|
|
@@ -1027,12 +1309,15 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1027
1309
|
this.emit("transcript.user", entry);
|
|
1028
1310
|
});
|
|
1029
1311
|
provider.on("userSpeechStarted", () => {
|
|
1312
|
+
this.userSpeechActive = true;
|
|
1313
|
+
this.userSpeechStartedAtMs = Date.now();
|
|
1030
1314
|
this.clearIdleTimer();
|
|
1031
1315
|
this.nudgeCount = 0;
|
|
1032
1316
|
this.emit("user.speech.started");
|
|
1033
1317
|
this.handleBargeIn();
|
|
1034
1318
|
});
|
|
1035
1319
|
provider.on("userSpeechStopped", () => {
|
|
1320
|
+
this.userSpeechActive = false;
|
|
1036
1321
|
if (this.blockedUserTurn === "speaking") this.blockedUserTurn = "committed";
|
|
1037
1322
|
this.emit("user.speech.ended");
|
|
1038
1323
|
});
|
|
@@ -1054,6 +1339,11 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1054
1339
|
}
|
|
1055
1340
|
handleInboundMedia(payload) {
|
|
1056
1341
|
if (this.stateValue === "ended" || this.stateValue === "ending") return;
|
|
1342
|
+
if (this.noiseVad) {
|
|
1343
|
+
const excluded = this.isUserSpeechExcluded() || this.tracker.isPlaybackActive() || this.pregreeting !== null && !this.pregreeting.played;
|
|
1344
|
+
const proposal = this.noiseVad.onInboundFrame(payload, excluded);
|
|
1345
|
+
if (proposal) this.handleVadProposal(proposal);
|
|
1346
|
+
}
|
|
1057
1347
|
if (this.pregreeting && !this.pregreeting.played) return;
|
|
1058
1348
|
if (this.deps.options.deafness.ignoreUserAudioUntilFirstTurnDone && !this.firstTurnDone) return;
|
|
1059
1349
|
if (this.deps.options.deafness.muteDuringToolExecution && this.runningTools.size > 0) return;
|
|
@@ -1070,6 +1360,51 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1070
1360
|
for (const payload of this.inboundBuffer) this.provider.sendAudio(payload);
|
|
1071
1361
|
this.inboundBuffer = [];
|
|
1072
1362
|
}
|
|
1363
|
+
/** Caller speech is excluded from the noise floor, capped so a missing
|
|
1364
|
+
* speech-stopped event (Gemini) can't latch the meter off forever. */
|
|
1365
|
+
isUserSpeechExcluded() {
|
|
1366
|
+
return this.userSpeechActive && Date.now() - this.userSpeechStartedAtMs < MAX_SPEECH_EXCLUSION_MS;
|
|
1367
|
+
}
|
|
1368
|
+
handleVadProposal(proposal) {
|
|
1369
|
+
if (this.vadOpsInFlight > 0) return;
|
|
1370
|
+
const willAutoApply = (this.deps.options.noiseAdaptiveVad?.mode ?? "auto") === "auto" && proposal.autoApplicable && this.provider?.capabilities.sessionUpdate === true;
|
|
1371
|
+
this.emit("vad.suggestion", {
|
|
1372
|
+
...proposal,
|
|
1373
|
+
willAutoApply
|
|
1374
|
+
});
|
|
1375
|
+
if (!willAutoApply) {
|
|
1376
|
+
this.noiseVad?.markSuggested(proposal);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
this.vadOpsInFlight++;
|
|
1380
|
+
const revisionAtPropose = this.vadRevision;
|
|
1381
|
+
this.applyAdaptiveVadUpdate(proposal, revisionAtPropose).catch((error) => {
|
|
1382
|
+
this.noiseVad?.defer();
|
|
1383
|
+
this.log.warn("noise-adaptive VAD apply failed", { error: String(error) });
|
|
1384
|
+
}).finally(() => {
|
|
1385
|
+
this.vadOpsInFlight--;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Adaptive applies promote NOTHING until the provider ACKs: a timed-out
|
|
1390
|
+
* update must not leak into vadOverride, or the desync-reconnect would
|
|
1391
|
+
* apply a config the controller never committed (dual truth). Contrast
|
|
1392
|
+
* with updateVad(), where the user's declared intent persists regardless.
|
|
1393
|
+
*/
|
|
1394
|
+
async applyAdaptiveVadUpdate(proposal, revisionAtPropose) {
|
|
1395
|
+
const provider = this.provider;
|
|
1396
|
+
if (!provider || this.stateValue === "ended") {
|
|
1397
|
+
this.noiseVad?.defer();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
const acked = await provider.updateSession({ vad: proposal.suggested }, { awaitAck: true });
|
|
1401
|
+
if (revisionAtPropose !== this.vadRevision) return;
|
|
1402
|
+
if (acked === true) {
|
|
1403
|
+
this.vadOverride = proposal.suggested;
|
|
1404
|
+
this.noiseVad?.commitApplied(proposal);
|
|
1405
|
+
this.emit("vad.adjusted", proposal);
|
|
1406
|
+
} else this.noiseVad?.defer();
|
|
1407
|
+
}
|
|
1073
1408
|
handleMarkEcho(name) {
|
|
1074
1409
|
if (name === PREGREETING_MARK) {
|
|
1075
1410
|
if (this.pregreeting && !this.pregreeting.played) {
|
|
@@ -1866,6 +2201,9 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1866
2201
|
const PREGREETING_MARK = "pre:greeting";
|
|
1867
2202
|
/** 400ms per frame — matches production burst-write implementations. */
|
|
1868
2203
|
const PREGREETING_CHUNK_BYTES = 3200;
|
|
2204
|
+
function toError(value) {
|
|
2205
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
2206
|
+
}
|
|
1869
2207
|
function safeJsonStringify(value) {
|
|
1870
2208
|
try {
|
|
1871
2209
|
return JSON.stringify(value) ?? "null";
|
|
@@ -2000,6 +2338,7 @@ var TwilioRealtimeBridge = class extends require_events.TypedEmitter {
|
|
|
2000
2338
|
transport,
|
|
2001
2339
|
start,
|
|
2002
2340
|
providerFactory: this.config.provider,
|
|
2341
|
+
fallbacks: this.config.fallbacks,
|
|
2003
2342
|
agent,
|
|
2004
2343
|
options,
|
|
2005
2344
|
store: this.store,
|
|
@@ -2121,6 +2460,7 @@ exports.DEFAULT_RECONNECT_POLICY = DEFAULT_RECONNECT_POLICY;
|
|
|
2121
2460
|
exports.DEFAULT_SESSION_OPTIONS = DEFAULT_SESSION_OPTIONS;
|
|
2122
2461
|
exports.InMemorySessionStore = require_InMemorySessionStore.InMemorySessionStore;
|
|
2123
2462
|
exports.InterruptionController = InterruptionController;
|
|
2463
|
+
exports.NoiseAdaptiveVadController = NoiseAdaptiveVadController;
|
|
2124
2464
|
exports.PlaybackTracker = PlaybackTracker;
|
|
2125
2465
|
exports.SessionContext = SessionContext;
|
|
2126
2466
|
exports.TwilioRealtimeBridge = TwilioRealtimeBridge;
|