@mentra/engine 3.2.1-dev.278 → 3.2.1-dev.282
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/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +56 -6
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +162 -40
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/CallGainSweep.d.ts +97 -0
- package/build/services/CallGainSweep.d.ts.map +1 -0
- package/build/services/CallGainSweep.js +249 -0
- package/build/services/CallGainSweep.js.map +1 -0
- package/build/services/GlassesMicProbe.d.ts +2 -0
- package/build/services/GlassesMicProbe.d.ts.map +1 -1
- package/build/services/GlassesMicProbe.js +20 -19
- package/build/services/GlassesMicProbe.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +18 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +131 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MicSessionManager.d.ts +83 -0
- package/build/services/MicSessionManager.d.ts.map +1 -0
- package/build/services/MicSessionManager.js +201 -0
- package/build/services/MicSessionManager.js.map +1 -0
- package/build/services/MicStateCoordinator.d.ts +71 -5
- package/build/services/MicStateCoordinator.d.ts.map +1 -1
- package/build/services/MicStateCoordinator.js +161 -10
- package/build/services/MicStateCoordinator.js.map +1 -1
- package/build/services/micPolicy.d.ts +131 -0
- package/build/services/micPolicy.d.ts.map +1 -0
- package/build/services/micPolicy.js +140 -0
- package/build/services/micPolicy.js.map +1 -0
- package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
- package/build/stores/bluetoothSettingKeys.js +3 -0
- package/build/stores/bluetoothSettingKeys.js.map +1 -1
- package/build/stores/settings.d.ts +5 -0
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +62 -0
- package/build/stores/settings.js.map +1 -1
- package/build/utils/pcm16.d.ts +20 -0
- package/build/utils/pcm16.d.ts.map +1 -1
- package/build/utils/pcm16.js +32 -1
- package/build/utils/pcm16.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +167 -40
- package/src/services/CallGainSweep.ts +318 -0
- package/src/services/GlassesMicProbe.ts +20 -17
- package/src/services/LocalMiniappRuntime.ts +152 -3
- package/src/services/MicSessionManager.ts +258 -0
- package/src/services/MicStateCoordinator.ts +153 -10
- package/src/services/micPolicy.ts +198 -0
- package/src/stores/bluetoothSettingKeys.ts +3 -0
- package/src/stores/settings.ts +66 -0
- package/src/utils/pcm16.ts +43 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microphone use-case policy.
|
|
3
|
+
*
|
|
4
|
+
* Applications say what they are doing ("voice_call"); the engine decides what
|
|
5
|
+
* that requires of the hardware. Nothing above this file knows that a voice
|
|
6
|
+
* call means ADC index 14, and no miniapp can ask for a gain directly.
|
|
7
|
+
*
|
|
8
|
+
* Pure: every function here is a function of the live session set. The lease
|
|
9
|
+
* bookkeeping lives in MicSessionManager, the merge with OS preferences lives
|
|
10
|
+
* in MicStateCoordinator, and the wire write lives below that again.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What an application is doing with the microphone. */
|
|
14
|
+
export type MicUseCase = "voice_call" | "transcription" | "voice_assistant" | "diagnostic"
|
|
15
|
+
|
|
16
|
+
/** Which microphone the audio comes from. */
|
|
17
|
+
export type MicSource = "glasses" | "phone"
|
|
18
|
+
|
|
19
|
+
/** Subset of the `mic_tuning` wire payload. Fields left out keep firmware defaults. */
|
|
20
|
+
export type MicTuningProfile = {
|
|
21
|
+
/** codec_adc_vol index, 0-15. */
|
|
22
|
+
gain?: number
|
|
23
|
+
/** Center-mic RMS to open the gate. */
|
|
24
|
+
open?: number
|
|
25
|
+
/** Center-mic RMS to close it again. */
|
|
26
|
+
close?: number
|
|
27
|
+
/** Open threshold while the speaker is elevated. */
|
|
28
|
+
sp_open?: number
|
|
29
|
+
/** Close threshold while the speaker is elevated. */
|
|
30
|
+
sp_close?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `codec_adc_vol[]`: index to dB. Index 0 is mute and is never offered. */
|
|
34
|
+
export const GAIN_DB = [-99, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 32]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mirrored from `center_mic_vad_get_default_config` and `CODEC_SADC_VOL`.
|
|
38
|
+
*
|
|
39
|
+
* The four RMS numbers are measured *after* the ADC gain, so they are only
|
|
40
|
+
* meaningful next to the gain they were calibrated at, which is index 15.
|
|
41
|
+
*/
|
|
42
|
+
export const MIC_TUNING_FIRMWARE_DEFAULTS = {
|
|
43
|
+
gain: 15,
|
|
44
|
+
open: 1350,
|
|
45
|
+
close: 945,
|
|
46
|
+
sp_open: 2900,
|
|
47
|
+
sp_close: 1600,
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Re-express the firmware's thresholds at a different gain.
|
|
52
|
+
*
|
|
53
|
+
* The gate compares post-gain RMS, so moving the gain without moving the
|
|
54
|
+
* thresholds silently changes what the gate means: at -8 dB the wearer has to
|
|
55
|
+
* be 2.5x louder to clear a number that was chosen for +32 dB. Scaling both by
|
|
56
|
+
* the same factor keeps the *acoustic* trip point the profile was tuned for,
|
|
57
|
+
* for the wearer and for speaker leak alike.
|
|
58
|
+
*/
|
|
59
|
+
export function scaleMicTuningToGain(gain: number): Required<MicTuningProfile> {
|
|
60
|
+
const index = Math.min(GAIN_DB.length - 1, Math.max(1, Math.round(gain)))
|
|
61
|
+
const factor = Math.pow(10, (GAIN_DB[index]! - GAIN_DB[MIC_TUNING_FIRMWARE_DEFAULTS.gain]!) / 20)
|
|
62
|
+
return {
|
|
63
|
+
gain: index,
|
|
64
|
+
open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.open * factor),
|
|
65
|
+
close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.close * factor),
|
|
66
|
+
sp_open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_open * factor),
|
|
67
|
+
sp_close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_close * factor),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A live microphone lease. */
|
|
72
|
+
export type MicSessionSpec = {
|
|
73
|
+
useCase: MicUseCase
|
|
74
|
+
source: MicSource
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the hardware should do, given every live session. */
|
|
78
|
+
export type ResolvedMicPolicy = {
|
|
79
|
+
/** Someone needs a continuous raw-PCM timeline, which also forces hardware VAD off. */
|
|
80
|
+
rawPcm: boolean
|
|
81
|
+
/** Pin the Bluetooth SDK to the glasses microphone. Only the glasses can be pinned. */
|
|
82
|
+
pinGlasses: boolean
|
|
83
|
+
/** Tuning override for the glasses, or null to leave the OS value in force. */
|
|
84
|
+
micTuning: MicTuningProfile | null
|
|
85
|
+
/** Run the center-mic loudness gate, or null to leave the OS value in force. */
|
|
86
|
+
loudnessGate: boolean | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Platform facts the policy cannot infer from the sessions alone. */
|
|
90
|
+
export type MicPlatformCaps = {
|
|
91
|
+
/**
|
|
92
|
+
* Whether this platform can take the wearer's voice off the glasses as raw
|
|
93
|
+
* PCM over BLE LC3. False on iOS, which has no `setMicSourcePin` and never
|
|
94
|
+
* selects the `ble-lc3` uplink.
|
|
95
|
+
*/
|
|
96
|
+
glassesPcmUplink: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mentra Live ships CODEC_SADC_VOL = 15, the last entry of codec_adc_vol[] and
|
|
101
|
+
* +32 dB. That table steps 2 dB at a time everywhere except the final step,
|
|
102
|
+
* which jumps 6 dB from +26, so the default sits at the ceiling one oversized
|
|
103
|
+
* step above everything else. It suits a wearer dictating to a transcription
|
|
104
|
+
* miniapp across a room; it clips a wearer talking into a Teams call.
|
|
105
|
+
*
|
|
106
|
+
* Index 14 is +26 dB: one step down, and the step that removes the table's
|
|
107
|
+
* anomalous jump. A same-voice sweep showed 15 rails hard (2.5–4.2% clip);
|
|
108
|
+
* 14/13/12 still kiss the rail on syllable tips. 13 is +24 dB, the middle
|
|
109
|
+
* of that band, while we listen for loudness vs residual clip.
|
|
110
|
+
*
|
|
111
|
+
* The thresholds ride along with the gain rather than being stated here, so
|
|
112
|
+
* changing the index above cannot leave the gate calibrated for a level the
|
|
113
|
+
* ADC no longer produces.
|
|
114
|
+
*/
|
|
115
|
+
export const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {
|
|
116
|
+
voice_call: scaleMicTuningToGain(13),
|
|
117
|
+
transcription: {},
|
|
118
|
+
voice_assistant: {},
|
|
119
|
+
diagnostic: {},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Use cases that run the center-mic loudness gate ("Barrier").
|
|
124
|
+
*
|
|
125
|
+
* A voice call is the one place it earns its keep. There is no echo canceller
|
|
126
|
+
* on the LC3 uplink — `MENTRA_LC3_SPEECH_PROCESSING` is 0 and the capture path
|
|
127
|
+
* ignores the playback reference — so without a gate the far end hears itself
|
|
128
|
+
* through the wearer's speaker. Barrier is the safe half of the firmware's two
|
|
129
|
+
* gates: it zeroes a quiet frame but never stops transmitting, where VAD drops
|
|
130
|
+
* the stream outright. Paired with the speaker-elevated thresholds above, that
|
|
131
|
+
* suppresses leak while the far end is talking and costs the wearer nothing.
|
|
132
|
+
*/
|
|
133
|
+
export const MIC_USE_CASE_LOUDNESS_GATE: Record<MicUseCase, boolean> = {
|
|
134
|
+
voice_call: true,
|
|
135
|
+
transcription: false,
|
|
136
|
+
voice_assistant: false,
|
|
137
|
+
diagnostic: false,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Packages allowed to hold a `voice_call` session.
|
|
142
|
+
*
|
|
143
|
+
* Which app may make a voice call is policy, so it lives beside the profiles
|
|
144
|
+
* rather than in the request handler that enforces it.
|
|
145
|
+
*/
|
|
146
|
+
export const VOICE_CALL_PACKAGES: readonly string[] = ["com.mentra.call"]
|
|
147
|
+
|
|
148
|
+
/** Every use case, for validating an inbound request. */
|
|
149
|
+
export const MIC_USE_CASES: readonly MicUseCase[] = [
|
|
150
|
+
"voice_call",
|
|
151
|
+
"transcription",
|
|
152
|
+
"voice_assistant",
|
|
153
|
+
"diagnostic",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
/** Owners of engine-internal sessions. Miniapps cannot claim these use cases. */
|
|
157
|
+
export const ENGINE_OWNER_PREFIX = "engine:"
|
|
158
|
+
|
|
159
|
+
/** Use cases only engine features may acquire. */
|
|
160
|
+
export const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = ["diagnostic"]
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve every live session into one hardware state.
|
|
164
|
+
*
|
|
165
|
+
* On a platform without a glasses PCM uplink a glasses session is a lease and
|
|
166
|
+
* nothing more: it satisfies ownership checks so an iOS call can still be
|
|
167
|
+
* modelled the same way, but it claims no PCM, pins nothing, and applies no
|
|
168
|
+
* gain, because the wearer's voice does not reach the call through the BES
|
|
169
|
+
* BLE path there.
|
|
170
|
+
*/
|
|
171
|
+
export function resolveMicPolicy(
|
|
172
|
+
sessions: readonly MicSessionSpec[],
|
|
173
|
+
caps: MicPlatformCaps,
|
|
174
|
+
): ResolvedMicPolicy {
|
|
175
|
+
const effective = caps.glassesPcmUplink ? sessions : sessions.filter((s) => s.source !== "glasses")
|
|
176
|
+
|
|
177
|
+
let pinGlasses = false
|
|
178
|
+
let loudnessGate = false
|
|
179
|
+
let winning: MicTuningProfile | null = null
|
|
180
|
+
for (const session of effective) {
|
|
181
|
+
if (session.source !== "glasses") continue
|
|
182
|
+
pinGlasses = true
|
|
183
|
+
if (MIC_USE_CASE_LOUDNESS_GATE[session.useCase]) loudnessGate = true
|
|
184
|
+
const profile = MIC_USE_CASE_PROFILES[session.useCase]
|
|
185
|
+
if (typeof profile?.gain !== "number") continue
|
|
186
|
+
// Lowest index wins: clipping is the irreversible failure, a slightly
|
|
187
|
+
// quiet assistant is not. The whole profile travels with it, because its
|
|
188
|
+
// thresholds are only meaningful at its own gain.
|
|
189
|
+
if (winning === null || profile.gain < winning.gain!) winning = profile
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
rawPcm: effective.length > 0,
|
|
194
|
+
pinGlasses,
|
|
195
|
+
micTuning: winning,
|
|
196
|
+
loudnessGate: pinGlasses ? loudnessGate : null,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -7,6 +7,9 @@ export const MENTRA_LIVE_SETTING_KEYS: string[] = [
|
|
|
7
7
|
"preferred_mic",
|
|
8
8
|
"voice_activity_detection_enabled",
|
|
9
9
|
"loudness_gate_enabled",
|
|
10
|
+
// Effective tuning only. mic_tuning_desired is engine-side state and must
|
|
11
|
+
// never appear here, or a persisted super-mode value would reach the glasses.
|
|
12
|
+
"mic_tuning",
|
|
10
13
|
"core_token",
|
|
11
14
|
"auth_email",
|
|
12
15
|
"button_photo_size",
|
package/src/stores/settings.ts
CHANGED
|
@@ -464,6 +464,42 @@ export const SETTINGS: Record<string, Setting> = {
|
|
|
464
464
|
saveOnServer: true,
|
|
465
465
|
persist: true,
|
|
466
466
|
},
|
|
467
|
+
/*
|
|
468
|
+
* Mentra Live mic tuning, split in two on purpose.
|
|
469
|
+
*
|
|
470
|
+
* `mic_tuning_desired` is what the super user set. It persists so that
|
|
471
|
+
* turning super mode off and on again does not lose the calibration, and it
|
|
472
|
+
* stays out of BLUETOOTH_SETTING_KEYS so it can never reach the glasses on
|
|
473
|
+
* its own. It is device-local: this is a hardware calibration aid, not
|
|
474
|
+
* something that should follow an account onto another pair of glasses.
|
|
475
|
+
*
|
|
476
|
+
* `mic_tuning` is the effective value, derived in getBluetoothSettings as
|
|
477
|
+
* `super_mode ? desired : {}`. It is the only one native ever sees. Because
|
|
478
|
+
* it is not persisted and native seeds no default for it, a process that
|
|
479
|
+
* connects before the engine has authorized anything can only send a reset
|
|
480
|
+
* -- the super-mode guarantee is then a property of what native can hold,
|
|
481
|
+
* not of which initialiser happened to run first.
|
|
482
|
+
*
|
|
483
|
+
* `{}` rather than null for "no tuning": the native store drops null writes,
|
|
484
|
+
* so null would leave a previously applied value in place.
|
|
485
|
+
*
|
|
486
|
+
* The empty object is a stable singleton so GlassesSettingsSync's reference
|
|
487
|
+
* diff does not treat every unrelated settings write as a mic_tuning change.
|
|
488
|
+
*/
|
|
489
|
+
mic_tuning_desired: {
|
|
490
|
+
key: "mic_tuning_desired",
|
|
491
|
+
defaultValue: () => null,
|
|
492
|
+
writable: true,
|
|
493
|
+
saveOnServer: false,
|
|
494
|
+
persist: true,
|
|
495
|
+
},
|
|
496
|
+
mic_tuning: {
|
|
497
|
+
key: "mic_tuning",
|
|
498
|
+
defaultValue: () => ({}),
|
|
499
|
+
writable: true,
|
|
500
|
+
saveOnServer: false,
|
|
501
|
+
persist: false,
|
|
502
|
+
},
|
|
467
503
|
always_on_status_bar: {
|
|
468
504
|
key: "always_on_status_bar",
|
|
469
505
|
defaultValue: () => false,
|
|
@@ -757,6 +793,8 @@ export const BLUETOOTH_SETTING_KEYS: string[] = [
|
|
|
757
793
|
SETTINGS.power_saving_mode.key,
|
|
758
794
|
SETTINGS.voice_activity_detection_enabled.key,
|
|
759
795
|
SETTINGS.loudness_gate_enabled.key,
|
|
796
|
+
// Effective tuning only; mic_tuning_desired deliberately stays engine-side.
|
|
797
|
+
SETTINGS.mic_tuning.key,
|
|
760
798
|
SETTINGS.lc3_frame_size.key,
|
|
761
799
|
SETTINGS.preferred_mic.key,
|
|
762
800
|
SETTINGS.screen_disabled.key,
|
|
@@ -819,6 +857,9 @@ export const PAIRING_IDENTITY_KEYS: string[] = Object.values(SETTINGS)
|
|
|
819
857
|
.filter((setting) => setting.nativeAuthoritative)
|
|
820
858
|
.map((setting) => setting.key)
|
|
821
859
|
|
|
860
|
+
/** Stable empty effective tuning. Native reads this as `{"reset":1}`. */
|
|
861
|
+
const EMPTY_MIC_TUNING: Record<string, number> = Object.freeze({})
|
|
862
|
+
|
|
822
863
|
// const PER_GLASSES_SETTINGS_KEYS: string[] = [SETTINGS.preferred_mic.key]
|
|
823
864
|
|
|
824
865
|
export interface SettingsState {
|
|
@@ -834,6 +875,11 @@ export interface SettingsState {
|
|
|
834
875
|
loadAllSettings: () => AsyncResult<void, Error>
|
|
835
876
|
// Utility methods
|
|
836
877
|
getBluetoothSettings: () => Record<string, any>
|
|
878
|
+
/**
|
|
879
|
+
* The mic tuning native is allowed to send: the desired value while super
|
|
880
|
+
* mode is on, `{}` (meaning "reset to firmware defaults") otherwise.
|
|
881
|
+
*/
|
|
882
|
+
getEffectiveMicTuning: () => Record<string, number>
|
|
837
883
|
resetAllSettingsLocally: () => void
|
|
838
884
|
}
|
|
839
885
|
|
|
@@ -1193,10 +1239,30 @@ export const useSettingsStore = create<SettingsState>()(
|
|
|
1193
1239
|
if (key === SETTINGS.core_token.key && (typeof value !== "string" || value.trim().length === 0)) {
|
|
1194
1240
|
continue
|
|
1195
1241
|
}
|
|
1242
|
+
if (key === SETTINGS.mic_tuning.key) {
|
|
1243
|
+
// Derived here rather than stored, so there is no path that can push
|
|
1244
|
+
// a persisted tuning to the glasses while super mode is off. Every
|
|
1245
|
+
// sync route (change diff, on-connect replay, full seed) reads this.
|
|
1246
|
+
bluetoothSettings[key] = state.getEffectiveMicTuning()
|
|
1247
|
+
continue
|
|
1248
|
+
}
|
|
1196
1249
|
bluetoothSettings[key] = value
|
|
1197
1250
|
}
|
|
1198
1251
|
return bluetoothSettings
|
|
1199
1252
|
},
|
|
1253
|
+
getEffectiveMicTuning: () => {
|
|
1254
|
+
const state = get()
|
|
1255
|
+
if (!state.getSetting(SETTINGS.super_mode.key)) return EMPTY_MIC_TUNING
|
|
1256
|
+
const desired = state.getSetting(SETTINGS.mic_tuning_desired.key)
|
|
1257
|
+
if (!desired || typeof desired !== "object") return EMPTY_MIC_TUNING
|
|
1258
|
+
const effective: Record<string, number> = {}
|
|
1259
|
+
for (const [key, value] of Object.entries(desired as Record<string, unknown>)) {
|
|
1260
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1261
|
+
effective[key] = Math.round(value)
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return Object.keys(effective).length === 0 ? EMPTY_MIC_TUNING : effective
|
|
1265
|
+
},
|
|
1200
1266
|
resetAllSettingsLocally: () => {
|
|
1201
1267
|
set((_state) => ({
|
|
1202
1268
|
settings: getDefaultSettings(),
|
package/src/utils/pcm16.ts
CHANGED
|
@@ -29,6 +29,11 @@ export function pcmDataView(frame: unknown): DataView | null {
|
|
|
29
29
|
return null
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Signed 16-bit full scale. `-32768` abs-counts as 32768 and is also a rail. */
|
|
33
|
+
export const PCM16_FULL_SCALE = 32767
|
|
34
|
+
/** Headroom line: loud speech that is not yet hard-clipped. */
|
|
35
|
+
export const PCM16_NEAR_CLIP = 30000
|
|
36
|
+
|
|
32
37
|
export type Pcm16Level = {
|
|
33
38
|
/** Mean absolute sample value (16-bit scale). ~30–60 is a quiet room on Mentra Live LC3. */
|
|
34
39
|
meanAbs: number
|
|
@@ -36,6 +41,31 @@ export type Pcm16Level = {
|
|
|
36
41
|
peak: number
|
|
37
42
|
/** Samples counted. */
|
|
38
43
|
samples: number
|
|
44
|
+
/** Samples at the int16 rail (`>= 32767`). */
|
|
45
|
+
clipped: number
|
|
46
|
+
/** Samples at or above [PCM16_NEAR_CLIP]. */
|
|
47
|
+
nearClip: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Pcm16WindowStats = Pcm16Level & {
|
|
51
|
+
/** `peak / 32767`, percent. */
|
|
52
|
+
peakPct: number
|
|
53
|
+
/** `clipped / samples`, percent. */
|
|
54
|
+
clipPct: number
|
|
55
|
+
/** `nearClip / samples`, percent. */
|
|
56
|
+
nearClipPct: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Percentages for a closed window. Zero samples → all percents 0. */
|
|
60
|
+
export function pcm16WindowStats(level: Pcm16Level): Pcm16WindowStats {
|
|
61
|
+
const denom = level.samples || 1
|
|
62
|
+
const pct = (count: number) => Math.round((count / denom) * 1000) / 10
|
|
63
|
+
return {
|
|
64
|
+
...level,
|
|
65
|
+
peakPct: Math.round((level.peak / PCM16_FULL_SCALE) * 1000) / 10,
|
|
66
|
+
clipPct: level.samples ? pct(level.clipped) : 0,
|
|
67
|
+
nearClipPct: level.samples ? pct(level.nearClip) : 0,
|
|
68
|
+
}
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
/** Level statistics over a batch of PCM16 frames. Unreadable frames are skipped, never thrown on. */
|
|
@@ -43,6 +73,8 @@ export function summarizePcm16(frames: unknown[]): Pcm16Level {
|
|
|
43
73
|
let sum = 0
|
|
44
74
|
let peak = 0
|
|
45
75
|
let samples = 0
|
|
76
|
+
let clipped = 0
|
|
77
|
+
let nearClip = 0
|
|
46
78
|
for (const frame of frames) {
|
|
47
79
|
const view = pcmDataView(frame)
|
|
48
80
|
if (!view) continue
|
|
@@ -51,10 +83,12 @@ export function summarizePcm16(frames: unknown[]): Pcm16Level {
|
|
|
51
83
|
const v = Math.abs(view.getInt16(i * 2, true))
|
|
52
84
|
sum += v
|
|
53
85
|
if (v > peak) peak = v
|
|
86
|
+
if (v >= PCM16_FULL_SCALE) clipped++
|
|
87
|
+
if (v >= PCM16_NEAR_CLIP) nearClip++
|
|
54
88
|
}
|
|
55
89
|
samples += n
|
|
56
90
|
}
|
|
57
|
-
return {meanAbs: samples ? Math.round(sum / samples) : 0, peak, samples}
|
|
91
|
+
return {meanAbs: samples ? Math.round(sum / samples) : 0, peak, samples, clipped, nearClip}
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
/**
|
|
@@ -65,6 +99,8 @@ export class Pcm16LevelMeter {
|
|
|
65
99
|
private sum = 0
|
|
66
100
|
private peakValue = 0
|
|
67
101
|
private count = 0
|
|
102
|
+
private clipped = 0
|
|
103
|
+
private nearClip = 0
|
|
68
104
|
|
|
69
105
|
add(frame: unknown): void {
|
|
70
106
|
const view = pcmDataView(frame)
|
|
@@ -74,6 +110,8 @@ export class Pcm16LevelMeter {
|
|
|
74
110
|
const v = Math.abs(view.getInt16(i * 2, true))
|
|
75
111
|
this.sum += v
|
|
76
112
|
if (v > this.peakValue) this.peakValue = v
|
|
113
|
+
if (v >= PCM16_FULL_SCALE) this.clipped++
|
|
114
|
+
if (v >= PCM16_NEAR_CLIP) this.nearClip++
|
|
77
115
|
}
|
|
78
116
|
this.count += n
|
|
79
117
|
}
|
|
@@ -84,10 +122,14 @@ export class Pcm16LevelMeter {
|
|
|
84
122
|
meanAbs: this.count ? Math.round(this.sum / this.count) : 0,
|
|
85
123
|
peak: this.peakValue,
|
|
86
124
|
samples: this.count,
|
|
125
|
+
clipped: this.clipped,
|
|
126
|
+
nearClip: this.nearClip,
|
|
87
127
|
}
|
|
88
128
|
this.sum = 0
|
|
89
129
|
this.peakValue = 0
|
|
90
130
|
this.count = 0
|
|
131
|
+
this.clipped = 0
|
|
132
|
+
this.nearClip = 0
|
|
91
133
|
return level
|
|
92
134
|
}
|
|
93
135
|
}
|