@framers/agentos-ext-diarization 0.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.
Files changed (49) hide show
  1. package/LICENSE +23 -0
  2. package/SKILL.md +71 -0
  3. package/dist/ClusteringStrategy.d.ts +56 -0
  4. package/dist/ClusteringStrategy.d.ts.map +1 -0
  5. package/dist/ClusteringStrategy.js +123 -0
  6. package/dist/ClusteringStrategy.js.map +1 -0
  7. package/dist/DiarizationEngine.d.ts +45 -0
  8. package/dist/DiarizationEngine.d.ts.map +1 -0
  9. package/dist/DiarizationEngine.js +84 -0
  10. package/dist/DiarizationEngine.js.map +1 -0
  11. package/dist/DiarizationSession.d.ts +90 -0
  12. package/dist/DiarizationSession.d.ts.map +1 -0
  13. package/dist/DiarizationSession.js +139 -0
  14. package/dist/DiarizationSession.js.map +1 -0
  15. package/dist/LocalDiarizationBackend.d.ts +90 -0
  16. package/dist/LocalDiarizationBackend.d.ts.map +1 -0
  17. package/dist/LocalDiarizationBackend.js +177 -0
  18. package/dist/LocalDiarizationBackend.js.map +1 -0
  19. package/dist/ProviderDiarizationBackend.d.ts +54 -0
  20. package/dist/ProviderDiarizationBackend.d.ts.map +1 -0
  21. package/dist/ProviderDiarizationBackend.js +101 -0
  22. package/dist/ProviderDiarizationBackend.js.map +1 -0
  23. package/dist/SlidingWindowExtractor.d.ts +85 -0
  24. package/dist/SlidingWindowExtractor.d.ts.map +1 -0
  25. package/dist/SlidingWindowExtractor.js +140 -0
  26. package/dist/SlidingWindowExtractor.js.map +1 -0
  27. package/dist/SpeakerEmbeddingCache.d.ts +117 -0
  28. package/dist/SpeakerEmbeddingCache.d.ts.map +1 -0
  29. package/dist/SpeakerEmbeddingCache.js +193 -0
  30. package/dist/SpeakerEmbeddingCache.js.map +1 -0
  31. package/dist/index.d.ts +67 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +73 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/types.d.ts +177 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +13 -0
  38. package/dist/types.js.map +1 -0
  39. package/manifest.json +8 -0
  40. package/package.json +48 -0
  41. package/src/ClusteringStrategy.ts +139 -0
  42. package/src/DiarizationEngine.ts +94 -0
  43. package/src/DiarizationSession.ts +178 -0
  44. package/src/LocalDiarizationBackend.ts +201 -0
  45. package/src/ProviderDiarizationBackend.ts +119 -0
  46. package/src/SlidingWindowExtractor.ts +174 -0
  47. package/src/SpeakerEmbeddingCache.ts +228 -0
  48. package/src/index.ts +105 -0
  49. package/src/types.ts +221 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
package/manifest.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@framers/agentos-ext-diarization",
3
+ "version": "0.1.0",
4
+ "description": "Speaker diarization with provider delegation and local spectral-centroid clustering",
5
+ "kind": "diarization-provider",
6
+ "extensionId": "diarization",
7
+ "entryPoint": "./dist/index.js"
8
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@framers/agentos-ext-diarization",
3
+ "version": "0.2.0",
4
+ "description": "Speaker diarization extension pack for AgentOS voice pipeline — provider delegation and local clustering",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "SKILL.md",
18
+ "manifest.json"
19
+ ],
20
+ "peerDependencies": {
21
+ "@framers/agentos": "^0.1.0",
22
+ "onnxruntime-node": "^1.18.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "onnxruntime-node": {
26
+ "optional": true
27
+ }
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.5.0",
31
+ "vitest": "^1.6.0",
32
+ "@framers/agentos": "0.1.94"
33
+ },
34
+ "license": "MIT",
35
+ "author": "Frame.dev",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/framersai/agentos-extensions.git",
39
+ "directory": "registry/curated/voice/diarization"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "test": "vitest run"
47
+ }
48
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file ClusteringStrategy.ts
3
+ * @description Agglomerative clustering to merge drifted speaker centroids.
4
+ *
5
+ * Over the course of a long session, a single speaker's vocal characteristics
6
+ * may drift enough that two separate centroids are created for them.
7
+ * {@link ClusteringStrategy.mergeClusters} detects this by computing pairwise
8
+ * cosine similarity between all centroids and iteratively merging the closest
9
+ * pair until either no pair exceeds the merge threshold or the centroid count
10
+ * equals `expectedSpeakers`.
11
+ *
12
+ * @module diarization/ClusteringStrategy
13
+ */
14
+
15
+ import { cosineSimilarity } from './SpeakerEmbeddingCache.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Main class
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Agglomerative speaker-centroid merging strategy.
23
+ *
24
+ * This is an optional post-processing step applied by {@link DiarizationSession}
25
+ * when the number of tracked centroids exceeds the expected speaker count.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const strategy = new ClusteringStrategy(0.85);
30
+ * const mapping = strategy.mergeClusters(cache.centroids, 2);
31
+ * // mapping: Map<oldId, canonicalId>
32
+ * ```
33
+ */
34
+ export class ClusteringStrategy {
35
+ // -------------------------------------------------------------------------
36
+ // Constructor
37
+ // -------------------------------------------------------------------------
38
+
39
+ /**
40
+ * @param mergeThreshold - Minimum cosine similarity between two centroids
41
+ * for them to be considered the same speaker and merged.
42
+ * @defaultValue 0.85
43
+ */
44
+ constructor(private readonly mergeThreshold: number = 0.85) {}
45
+
46
+ // -------------------------------------------------------------------------
47
+ // Public API
48
+ // -------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Identify centroid pairs that should be merged and return a renaming map.
52
+ *
53
+ * The algorithm:
54
+ * 1. Compute all pairwise cosine similarities.
55
+ * 2. If `expectedSpeakers` is set and the current count exceeds it, merge
56
+ * the closest pair regardless of the threshold.
57
+ * 3. Otherwise merge pairs that exceed `mergeThreshold`.
58
+ * 4. Repeat until no further merges are possible or the count matches
59
+ * `expectedSpeakers`.
60
+ *
61
+ * The returned `Map<string, string>` maps every old centroid ID that was
62
+ * subsumed into a canonical ID. IDs that were not merged are not present in
63
+ * the map. Callers should rename all occurrences of a key to its value.
64
+ *
65
+ * @param centroids - Current centroid snapshot (id → embedding).
66
+ * @param expectedSpeakers - Optional upper bound on speaker count.
67
+ * @returns Rename map: `oldId → canonicalId`.
68
+ */
69
+ mergeClusters(
70
+ centroids: Map<string, Float32Array>,
71
+ expectedSpeakers?: number,
72
+ ): Map<string, string> {
73
+ // Build a mutable working copy so we can iteratively merge.
74
+ const working = new Map<string, Float32Array>(centroids);
75
+ // Accumulated rename mapping.
76
+ const renameMap = new Map<string, string>();
77
+
78
+ while (true) {
79
+ const ids = Array.from(working.keys());
80
+ const count = ids.length;
81
+
82
+ // Nothing to merge.
83
+ if (count < 2) break;
84
+
85
+ // Find the closest pair.
86
+ let bestSim = -Infinity;
87
+ let bestI = 0;
88
+ let bestJ = 1;
89
+
90
+ for (let i = 0; i < count; i++) {
91
+ for (let j = i + 1; j < count; j++) {
92
+ const sim = cosineSimilarity(working.get(ids[i]!)!, working.get(ids[j]!)!);
93
+ if (sim > bestSim) {
94
+ bestSim = sim;
95
+ bestI = i;
96
+ bestJ = j;
97
+ }
98
+ }
99
+ }
100
+
101
+ // Decide whether to merge.
102
+ const shouldMergeDueToThreshold = bestSim >= this.mergeThreshold;
103
+ const shouldMergeDueToCount =
104
+ expectedSpeakers !== undefined && count > expectedSpeakers;
105
+
106
+ if (!shouldMergeDueToThreshold && !shouldMergeDueToCount) break;
107
+
108
+ // Merge ids[bestJ] into ids[bestI] (keep the lexicographically earlier
109
+ // ID as the canonical one for determinism).
110
+ const keepId = ids[bestI]!;
111
+ const dropId = ids[bestJ]!;
112
+
113
+ // Average the two centroids (equal weight — simple heuristic).
114
+ const keepEmb = working.get(keepId)!;
115
+ const dropEmb = working.get(dropId)!;
116
+ const merged = new Float32Array(keepEmb.length);
117
+ for (let k = 0; k < keepEmb.length; k++) {
118
+ merged[k] = (keepEmb[k]! + dropEmb[k]!) / 2;
119
+ }
120
+
121
+ working.set(keepId, merged);
122
+ working.delete(dropId);
123
+
124
+ // Record the rename. Chase any existing mappings so the final map is
125
+ // transitively resolved.
126
+ renameMap.set(dropId, keepId);
127
+
128
+ // Resolve transitive renames: if dropId was itself a canonical target of
129
+ // an earlier merge, update those entries to point to keepId.
130
+ for (const [old, target] of renameMap) {
131
+ if (target === dropId) {
132
+ renameMap.set(old, keepId);
133
+ }
134
+ }
135
+ }
136
+
137
+ return renameMap;
138
+ }
139
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file DiarizationEngine.ts
3
+ * @description Factory that creates {@link DiarizationSession} instances,
4
+ * selecting the appropriate backend based on the supplied configuration.
5
+ *
6
+ * When a provider backend is requested (`config.backend === 'provider'`), the
7
+ * engine creates a {@link ProviderDiarizationBackend} and wraps it in a
8
+ * session. Otherwise it constructs the full local pipeline:
9
+ * {@link SlidingWindowExtractor} → {@link LocalDiarizationBackend} →
10
+ * {@link SpeakerEmbeddingCache}.
11
+ *
12
+ * @module diarization/DiarizationEngine
13
+ */
14
+
15
+ import type { IDiarizationEngine, IDiarizationSession, DiarizationConfig } from './types.js';
16
+ import { DiarizationSession } from './DiarizationSession.js';
17
+ import { SpeakerEmbeddingCache } from './SpeakerEmbeddingCache.js';
18
+ import { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
19
+ import { LocalDiarizationBackend } from './LocalDiarizationBackend.js';
20
+ import { ProviderDiarizationBackend } from './ProviderDiarizationBackend.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Default values
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.7;
27
+ const DEFAULT_CHUNK_SIZE_MS = 1500;
28
+ const DEFAULT_OVERLAP_MS = 500;
29
+ const DEFAULT_SAMPLE_RATE = 16_000;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Main class
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Factory for diarization sessions.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const engine = new DiarizationEngine();
41
+ *
42
+ * // Local mode (default)
43
+ * const session = engine.startSession();
44
+ * session.on('speaker_identified', ({ speakerId }) => console.log(speakerId));
45
+ *
46
+ * // Provider mode
47
+ * const providerSession = engine.startSession({ backend: 'provider' });
48
+ * sttSession.on('transcript', (e) => providerSession.labelTranscript(e));
49
+ * ```
50
+ */
51
+ export class DiarizationEngine implements IDiarizationEngine {
52
+ // -------------------------------------------------------------------------
53
+ // IDiarizationEngine implementation
54
+ // -------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Create and return a new {@link IDiarizationSession}.
58
+ *
59
+ * The backend is selected based on `config.backend`:
60
+ * - `'provider'` — use {@link ProviderDiarizationBackend}
61
+ * - `'local'` (default) — use {@link LocalDiarizationBackend} with a fresh
62
+ * {@link SpeakerEmbeddingCache} and {@link SlidingWindowExtractor}
63
+ *
64
+ * @param config - Optional session configuration.
65
+ * @returns A ready-to-use {@link DiarizationSession}.
66
+ */
67
+ startSession(config: DiarizationConfig = {}): IDiarizationSession {
68
+ const backend = config.backend ?? 'local';
69
+
70
+ if (backend === 'provider') {
71
+ return new DiarizationSession({
72
+ kind: 'provider',
73
+ backend: new ProviderDiarizationBackend(),
74
+ });
75
+ }
76
+
77
+ // Local mode — wire up the full pipeline.
78
+ const threshold = config.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
79
+ const chunkSizeMs = config.chunkSizeMs ?? DEFAULT_CHUNK_SIZE_MS;
80
+ const overlapMs = config.overlapMs ?? DEFAULT_OVERLAP_MS;
81
+ const sampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
82
+
83
+ const cache = new SpeakerEmbeddingCache(threshold);
84
+ const extractor = new SlidingWindowExtractor(chunkSizeMs, overlapMs, sampleRate);
85
+ const localBackend = new LocalDiarizationBackend(cache, extractor);
86
+
87
+ return new DiarizationSession({
88
+ kind: 'local',
89
+ backend: localBackend,
90
+ cache,
91
+ extractor,
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @file DiarizationSession.ts
3
+ * @description Live diarization session that wraps either a
4
+ * {@link ProviderDiarizationBackend} or a {@link LocalDiarizationBackend}.
5
+ *
6
+ * The session implements {@link IDiarizationSession} and bridges the raw audio
7
+ * / transcript inputs to the selected backend, forwarding `speaker_identified`
8
+ * and `segment_ready` events to callers.
9
+ *
10
+ * @module diarization/DiarizationSession
11
+ */
12
+
13
+ import { EventEmitter } from 'node:events';
14
+ import type { IDiarizationSession, AudioFrame, TranscriptEvent, DiarizedSegment, SpeakerIdentified } from './types.js';
15
+ import type { ProviderDiarizationBackend } from './ProviderDiarizationBackend.js';
16
+ import type { LocalDiarizationBackend } from './LocalDiarizationBackend.js';
17
+ import type { SpeakerEmbeddingCache } from './SpeakerEmbeddingCache.js';
18
+ import type { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Discriminated union for the backend
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Provider-delegated backend descriptor. */
25
+ interface ProviderBackendDescriptor {
26
+ kind: 'provider';
27
+ backend: ProviderDiarizationBackend;
28
+ }
29
+
30
+ /** Local-clustering backend descriptor. */
31
+ interface LocalBackendDescriptor {
32
+ kind: 'local';
33
+ backend: LocalDiarizationBackend;
34
+ cache: SpeakerEmbeddingCache;
35
+ extractor: SlidingWindowExtractor;
36
+ }
37
+
38
+ type BackendDescriptor = ProviderBackendDescriptor | LocalBackendDescriptor;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Main class
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * A live diarization session.
46
+ *
47
+ * Create instances via {@link DiarizationEngine.startSession} rather than
48
+ * constructing directly.
49
+ *
50
+ * ### Events
51
+ * - `speaker_identified` — payload: {@link SpeakerIdentified}
52
+ * - `segment_ready` — payload: {@link DiarizedSegment}
53
+ * - `error` — payload: `Error`
54
+ * - `close` — no payload
55
+ */
56
+ export class DiarizationSession extends EventEmitter implements IDiarizationSession {
57
+ // -------------------------------------------------------------------------
58
+ // Private state
59
+ // -------------------------------------------------------------------------
60
+
61
+ private closed = false;
62
+
63
+ /** Most recently identified speaker (used for transcript labelling in local mode). */
64
+ private lastSpeakerId: string | null = null;
65
+
66
+ // -------------------------------------------------------------------------
67
+ // Constructor
68
+ // -------------------------------------------------------------------------
69
+
70
+ /**
71
+ * @param descriptor - Resolved backend descriptor.
72
+ */
73
+ constructor(private readonly descriptor: BackendDescriptor) {
74
+ super();
75
+
76
+ if (descriptor.kind === 'local') {
77
+ // Forward speaker_identified events from the local backend.
78
+ descriptor.backend.on('speaker_identified', (payload: SpeakerIdentified) => {
79
+ this.lastSpeakerId = payload.speakerId;
80
+ this.emit('speaker_identified', payload);
81
+ });
82
+
83
+ descriptor.backend.start();
84
+ }
85
+ }
86
+
87
+ // -------------------------------------------------------------------------
88
+ // IDiarizationSession implementation
89
+ // -------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Push a raw audio frame into the pipeline.
93
+ *
94
+ * In local mode the frame is forwarded to the sliding-window extractor.
95
+ * In provider mode this is a no-op.
96
+ *
97
+ * @param frame - Audio frame to process.
98
+ */
99
+ pushAudio(frame: AudioFrame): void {
100
+ if (this.closed) return;
101
+
102
+ if (this.descriptor.kind === 'local') {
103
+ this.descriptor.extractor.pushAudio(frame);
104
+ }
105
+ // Provider mode: audio processing happens on the provider side.
106
+ }
107
+
108
+ /**
109
+ * Attach speaker labels to a transcript event and emit a segment.
110
+ *
111
+ * In provider mode, word-level speaker labels are extracted directly. In
112
+ * local mode, the most recently identified speaker is used.
113
+ *
114
+ * @param event - Transcript event from the STT provider.
115
+ * @returns The labelled {@link DiarizedSegment}, or `null` if no speaker
116
+ * information is available yet.
117
+ */
118
+ labelTranscript(event: TranscriptEvent): DiarizedSegment | null {
119
+ if (this.closed) return null;
120
+
121
+ let segment: DiarizedSegment | null = null;
122
+
123
+ if (this.descriptor.kind === 'provider') {
124
+ segment = this.descriptor.backend.pushTranscriptEvent(event);
125
+ } else {
126
+ // Local mode — use the last identified speaker.
127
+ if (this.lastSpeakerId !== null) {
128
+ segment = {
129
+ speakerId: this.lastSpeakerId,
130
+ text: event.text,
131
+ startMs: event.timestamp,
132
+ endMs: event.timestamp + event.text.length * 50, // rough estimate
133
+ confidence: 0.7,
134
+ };
135
+ }
136
+ }
137
+
138
+ if (segment) {
139
+ this.emit('segment_ready', segment);
140
+ }
141
+
142
+ return segment;
143
+ }
144
+
145
+ /**
146
+ * Pre-register a known speaker voiceprint.
147
+ *
148
+ * Only has effect in local mode. In provider mode, speaker IDs come
149
+ * directly from the STT provider and this call is a no-op.
150
+ *
151
+ * @param id - Human-readable speaker name.
152
+ * @param voiceprint - Reference embedding for this speaker.
153
+ */
154
+ enrollSpeaker(id: string, voiceprint: Float32Array): void {
155
+ if (this.descriptor.kind === 'local') {
156
+ this.descriptor.cache.enrollSpeaker(id, voiceprint);
157
+ }
158
+ // Provider mode: enrolment is handled by the provider; ignore here.
159
+ }
160
+
161
+ /**
162
+ * Terminate the session and release all resources.
163
+ *
164
+ * After `close()` no further events are emitted.
165
+ */
166
+ close(): void {
167
+ if (this.closed) return;
168
+ this.closed = true;
169
+
170
+ if (this.descriptor.kind === 'local') {
171
+ this.descriptor.backend.stop();
172
+ this.descriptor.extractor.flush();
173
+ }
174
+
175
+ this.emit('close');
176
+ this.removeAllListeners();
177
+ }
178
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @file LocalDiarizationBackend.ts
3
+ * @description Offline speaker diarization backend using a sliding-window
4
+ * spectral-centroid voiceprint and cosine similarity matching.
5
+ *
6
+ * Audio is accumulated in a {@link SlidingWindowExtractor}. When a full chunk
7
+ * is ready, a 16-dimensional feature vector is extracted and matched against
8
+ * cached speaker centroids. If no centroid exceeds the similarity threshold a
9
+ * new `Speaker_N` identity is created.
10
+ *
11
+ * The built-in feature extractor is intentionally lightweight. An ONNX
12
+ * x-vector model can replace {@link LocalDiarizationBackend.extractSimpleEmbedding}
13
+ * later without changing the surrounding API.
14
+ *
15
+ * @module diarization/LocalDiarizationBackend
16
+ */
17
+
18
+ import { EventEmitter } from 'node:events';
19
+ import type { SpeakerEmbeddingCache } from './SpeakerEmbeddingCache.js';
20
+ import type { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
21
+ import type { SpeakerIdentified } from './types.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Size of the local feature vector in dimensions. */
28
+ const FEATURE_DIMS = 16;
29
+
30
+ /** Number of octave-band buckets used by the feature extractor. */
31
+ const OCTAVE_BANDS = 4;
32
+
33
+ /** Dimensions per octave band (FEATURE_DIMS / OCTAVE_BANDS). */
34
+ const DIMS_PER_BAND = FEATURE_DIMS / OCTAVE_BANDS;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Main class
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Fully offline diarization backend.
42
+ *
43
+ * Connects a {@link SlidingWindowExtractor} to a {@link SpeakerEmbeddingCache}
44
+ * and emits `speaker_identified` events whenever a speaker label is resolved
45
+ * for a new audio chunk.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const cache = new SpeakerEmbeddingCache(0.7);
50
+ * const extractor = new SlidingWindowExtractor(1500, 500, 16000);
51
+ * const backend = new LocalDiarizationBackend(cache, extractor);
52
+ *
53
+ * backend.on('speaker_identified', ({ speakerId }) => console.log(speakerId));
54
+ * backend.start();
55
+ *
56
+ * microphone.on('frame', (f) => extractor.pushAudio(f));
57
+ * ```
58
+ */
59
+ export class LocalDiarizationBackend extends EventEmitter {
60
+ // -------------------------------------------------------------------------
61
+ // Constructor
62
+ // -------------------------------------------------------------------------
63
+
64
+ /**
65
+ * @param cache - Speaker voiceprint cache used for matching and centroid
66
+ * updates.
67
+ * @param extractor - Sliding-window extractor that supplies audio chunks.
68
+ */
69
+ constructor(
70
+ private readonly cache: SpeakerEmbeddingCache,
71
+ private readonly extractor: SlidingWindowExtractor,
72
+ ) {
73
+ super();
74
+ }
75
+
76
+ // -------------------------------------------------------------------------
77
+ // Public API
78
+ // -------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Attach the `'chunk_ready'` listener to the extractor and begin processing
82
+ * audio chunks.
83
+ *
84
+ * Calling `start()` more than once is safe — subsequent calls are no-ops
85
+ * because the same bound listener is reused.
86
+ */
87
+ start(): void {
88
+ this.extractor.on('chunk_ready', this._onChunkReady);
89
+ }
90
+
91
+ /**
92
+ * Detach from the extractor and stop processing.
93
+ */
94
+ stop(): void {
95
+ this.extractor.off('chunk_ready', this._onChunkReady);
96
+ }
97
+
98
+ // -------------------------------------------------------------------------
99
+ // Internal event handler
100
+ // -------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Bound handler for `'chunk_ready'` events from the extractor.
104
+ *
105
+ * Stored as an arrow-function property so the same reference is used for
106
+ * both `on()` and `off()`.
107
+ */
108
+ private readonly _onChunkReady = (chunk: Float32Array): void => {
109
+ const embedding = this.extractSimpleEmbedding(chunk);
110
+ const { speakerId } = this.cache.getOrCreateSpeaker(embedding);
111
+
112
+ const payload: SpeakerIdentified = {
113
+ speakerId,
114
+ confidence: 0.7,
115
+ timestamp: Date.now(),
116
+ };
117
+
118
+ this.emit('speaker_identified', payload);
119
+ };
120
+
121
+ // -------------------------------------------------------------------------
122
+ // Feature extraction
123
+ // -------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Extract a 16-dimensional voiceprint from a raw PCM chunk.
127
+ *
128
+ * Feature layout (4 octave bands × 4 features per band):
129
+ * - Band 0: sub-bass (0 – 500 Hz equivalent bucket)
130
+ * - Band 1: bass (500 – 2000 Hz equivalent bucket)
131
+ * - Band 2: mid (2000 – 4000 Hz equivalent bucket)
132
+ * - Band 3: high (4000 Hz+ equivalent bucket)
133
+ *
134
+ * For each band the following statistics are computed:
135
+ * - [0] RMS energy
136
+ * - [1] Spectral centroid (normalised to band range)
137
+ * - [2] Zero-crossing rate
138
+ * - [3] Delta energy (mean absolute frame-to-frame change)
139
+ *
140
+ * This is a placeholder implementation suitable for demos and testing.
141
+ * Replace with an ONNX x-vector model for production accuracy.
142
+ *
143
+ * @param chunk - Float32 PCM audio data.
144
+ * @returns 16-dimensional embedding vector.
145
+ */
146
+ extractSimpleEmbedding(chunk: Float32Array): Float32Array {
147
+ const features = new Float32Array(FEATURE_DIMS);
148
+ const n = chunk.length;
149
+
150
+ if (n === 0) return features;
151
+
152
+ // Split the chunk index range into OCTAVE_BANDS equal bands.
153
+ const bandSize = Math.ceil(n / OCTAVE_BANDS);
154
+
155
+ for (let band = 0; band < OCTAVE_BANDS; band++) {
156
+ const start = band * bandSize;
157
+ const end = Math.min(start + bandSize, n);
158
+ const bandLen = end - start;
159
+
160
+ if (bandLen === 0) continue;
161
+
162
+ // --- RMS energy ---
163
+ let sumSq = 0;
164
+ for (let i = start; i < end; i++) sumSq += (chunk[i]!) ** 2;
165
+ const rms = Math.sqrt(sumSq / bandLen);
166
+
167
+ // --- Spectral centroid (weighted mean index, normalised to [0,1]) ---
168
+ let weightedIdx = 0;
169
+ let totalAbs = 0;
170
+ for (let i = start; i < end; i++) {
171
+ const abs = Math.abs(chunk[i]!);
172
+ weightedIdx += (i - start) * abs;
173
+ totalAbs += abs;
174
+ }
175
+ const centroid = totalAbs > 0 ? weightedIdx / totalAbs / bandLen : 0;
176
+
177
+ // --- Zero-crossing rate ---
178
+ let zeroCrossings = 0;
179
+ for (let i = start + 1; i < end; i++) {
180
+ if ((chunk[i]! >= 0) !== (chunk[i - 1]! >= 0)) zeroCrossings++;
181
+ }
182
+ const zcr = zeroCrossings / (bandLen - 1);
183
+
184
+ // --- Delta energy (mean |diff| between consecutive samples) ---
185
+ let deltaSum = 0;
186
+ for (let i = start + 1; i < end; i++) {
187
+ deltaSum += Math.abs(chunk[i]! - chunk[i - 1]!);
188
+ }
189
+ const delta = deltaSum / (bandLen - 1);
190
+
191
+ // Write into the feature vector.
192
+ const base = band * DIMS_PER_BAND;
193
+ features[base + 0] = rms;
194
+ features[base + 1] = centroid;
195
+ features[base + 2] = zcr;
196
+ features[base + 3] = delta;
197
+ }
198
+
199
+ return features;
200
+ }
201
+ }