@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
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Framers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+
package/SKILL.md ADDED
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: diarization
3
+ description: Speaker diarization — identifies and tracks who is speaking at each moment in an audio stream
4
+ category: voice
5
+ ---
6
+
7
+ # Diarization Extension Pack
8
+
9
+ Speaker diarization for the AgentOS voice pipeline. Supports two modes:
10
+
11
+ 1. **Provider-delegated** — extracts speaker labels from STT provider word-level results (e.g. Deepgram with `diarize: true`). Zero additional compute, no voiceprint model needed.
12
+ 2. **Local clustering** — uses a sliding-window spectral-centroid voiceprint with agglomerative clustering, fully offline. An ONNX x-vector model can be plugged in later without API changes.
13
+
14
+ ## Setup
15
+
16
+ No API key required for local mode. For provider mode, enable diarization on your STT provider (e.g. `providerOptions.diarize: true` on the Deepgram STT pack).
17
+
18
+ ## Configuration
19
+
20
+ ```json
21
+ {
22
+ "voice": {
23
+ "diarization": "local"
24
+ }
25
+ }
26
+ ```
27
+
28
+ To use provider-delegated diarization:
29
+
30
+ ```json
31
+ {
32
+ "voice": {
33
+ "diarization": "provider",
34
+ "stt": "deepgram",
35
+ "providerOptions": { "diarize": true }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ### Speaker enrollment (optional)
41
+
42
+ Pre-register known speakers so the engine can label them by name instead of `Speaker_N`:
43
+
44
+ ```ts
45
+ await session.enrollSpeaker('Alice', aliceVoiceprintFloat32Array);
46
+ await session.enrollSpeaker('Bob', bobVoiceprintFloat32Array);
47
+ ```
48
+
49
+ ## Events
50
+
51
+ | Event | Payload | Description |
52
+ |-----------------------|----------------------|----------------------------------------------------------|
53
+ | `speaker_identified` | `SpeakerIdentified` | Emitted whenever the active speaker label changes |
54
+ | `segment_ready` | `DiarizedSegment` | A labelled audio or transcript segment is ready |
55
+ | `error` | `Error` | Unrecoverable diarization error |
56
+ | `close` | — | Session fully terminated |
57
+
58
+ ## Local backend feature extraction
59
+
60
+ The built-in feature extractor computes a 16-dimensional vector per 1.5 s window (0.5 s overlap):
61
+
62
+ - Dimensions 0–3: octave-band RMS energy (sub-bass, bass, mid, high)
63
+ - Dimensions 4–7: spectral centroid per octave band
64
+ - Dimensions 8–11: zero-crossing rate per octave band
65
+ - Dimensions 12–15: delta energy (frame-to-frame change) per octave band
66
+
67
+ This is intentionally lightweight. Replace `LocalDiarizationBackend.extractSimpleEmbedding()` with an ONNX x-vector model for production-quality voiceprints.
68
+
69
+ ## Clustering
70
+
71
+ `ClusteringStrategy` runs agglomerative merging whenever the centroid count exceeds `expectedSpeakers`. Centroids with cosine similarity above `mergeThreshold` (default 0.85) are collapsed into a single speaker identity.
@@ -0,0 +1,56 @@
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
+ * Agglomerative speaker-centroid merging strategy.
16
+ *
17
+ * This is an optional post-processing step applied by {@link DiarizationSession}
18
+ * when the number of tracked centroids exceeds the expected speaker count.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const strategy = new ClusteringStrategy(0.85);
23
+ * const mapping = strategy.mergeClusters(cache.centroids, 2);
24
+ * // mapping: Map<oldId, canonicalId>
25
+ * ```
26
+ */
27
+ export declare class ClusteringStrategy {
28
+ private readonly mergeThreshold;
29
+ /**
30
+ * @param mergeThreshold - Minimum cosine similarity between two centroids
31
+ * for them to be considered the same speaker and merged.
32
+ * @defaultValue 0.85
33
+ */
34
+ constructor(mergeThreshold?: number);
35
+ /**
36
+ * Identify centroid pairs that should be merged and return a renaming map.
37
+ *
38
+ * The algorithm:
39
+ * 1. Compute all pairwise cosine similarities.
40
+ * 2. If `expectedSpeakers` is set and the current count exceeds it, merge
41
+ * the closest pair regardless of the threshold.
42
+ * 3. Otherwise merge pairs that exceed `mergeThreshold`.
43
+ * 4. Repeat until no further merges are possible or the count matches
44
+ * `expectedSpeakers`.
45
+ *
46
+ * The returned `Map<string, string>` maps every old centroid ID that was
47
+ * subsumed into a canonical ID. IDs that were not merged are not present in
48
+ * the map. Callers should rename all occurrences of a key to its value.
49
+ *
50
+ * @param centroids - Current centroid snapshot (id → embedding).
51
+ * @param expectedSpeakers - Optional upper bound on speaker count.
52
+ * @returns Rename map: `oldId → canonicalId`.
53
+ */
54
+ mergeClusters(centroids: Map<string, Float32Array>, expectedSpeakers?: number): Map<string, string>;
55
+ }
56
+ //# sourceMappingURL=ClusteringStrategy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClusteringStrategy.d.ts","sourceRoot":"","sources":["../src/ClusteringStrategy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAkB;IAUjB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAL3C;;;;OAIG;gBAC0B,cAAc,GAAE,MAAa;IAM1D;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CACX,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EACpC,gBAAgB,CAAC,EAAE,MAAM,GACxB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;CAmEvB"}
@@ -0,0 +1,123 @@
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
+ import { cosineSimilarity } from './SpeakerEmbeddingCache.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Main class
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Agglomerative speaker-centroid merging strategy.
20
+ *
21
+ * This is an optional post-processing step applied by {@link DiarizationSession}
22
+ * when the number of tracked centroids exceeds the expected speaker count.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const strategy = new ClusteringStrategy(0.85);
27
+ * const mapping = strategy.mergeClusters(cache.centroids, 2);
28
+ * // mapping: Map<oldId, canonicalId>
29
+ * ```
30
+ */
31
+ export class ClusteringStrategy {
32
+ mergeThreshold;
33
+ // -------------------------------------------------------------------------
34
+ // Constructor
35
+ // -------------------------------------------------------------------------
36
+ /**
37
+ * @param mergeThreshold - Minimum cosine similarity between two centroids
38
+ * for them to be considered the same speaker and merged.
39
+ * @defaultValue 0.85
40
+ */
41
+ constructor(mergeThreshold = 0.85) {
42
+ this.mergeThreshold = mergeThreshold;
43
+ }
44
+ // -------------------------------------------------------------------------
45
+ // Public API
46
+ // -------------------------------------------------------------------------
47
+ /**
48
+ * Identify centroid pairs that should be merged and return a renaming map.
49
+ *
50
+ * The algorithm:
51
+ * 1. Compute all pairwise cosine similarities.
52
+ * 2. If `expectedSpeakers` is set and the current count exceeds it, merge
53
+ * the closest pair regardless of the threshold.
54
+ * 3. Otherwise merge pairs that exceed `mergeThreshold`.
55
+ * 4. Repeat until no further merges are possible or the count matches
56
+ * `expectedSpeakers`.
57
+ *
58
+ * The returned `Map<string, string>` maps every old centroid ID that was
59
+ * subsumed into a canonical ID. IDs that were not merged are not present in
60
+ * the map. Callers should rename all occurrences of a key to its value.
61
+ *
62
+ * @param centroids - Current centroid snapshot (id → embedding).
63
+ * @param expectedSpeakers - Optional upper bound on speaker count.
64
+ * @returns Rename map: `oldId → canonicalId`.
65
+ */
66
+ mergeClusters(centroids, expectedSpeakers) {
67
+ // Build a mutable working copy so we can iteratively merge.
68
+ const working = new Map(centroids);
69
+ // Accumulated rename mapping.
70
+ const renameMap = new Map();
71
+ while (true) {
72
+ const ids = Array.from(working.keys());
73
+ const count = ids.length;
74
+ // Nothing to merge.
75
+ if (count < 2)
76
+ break;
77
+ // Find the closest pair.
78
+ let bestSim = -Infinity;
79
+ let bestI = 0;
80
+ let bestJ = 1;
81
+ for (let i = 0; i < count; i++) {
82
+ for (let j = i + 1; j < count; j++) {
83
+ const sim = cosineSimilarity(working.get(ids[i]), working.get(ids[j]));
84
+ if (sim > bestSim) {
85
+ bestSim = sim;
86
+ bestI = i;
87
+ bestJ = j;
88
+ }
89
+ }
90
+ }
91
+ // Decide whether to merge.
92
+ const shouldMergeDueToThreshold = bestSim >= this.mergeThreshold;
93
+ const shouldMergeDueToCount = expectedSpeakers !== undefined && count > expectedSpeakers;
94
+ if (!shouldMergeDueToThreshold && !shouldMergeDueToCount)
95
+ break;
96
+ // Merge ids[bestJ] into ids[bestI] (keep the lexicographically earlier
97
+ // ID as the canonical one for determinism).
98
+ const keepId = ids[bestI];
99
+ const dropId = ids[bestJ];
100
+ // Average the two centroids (equal weight — simple heuristic).
101
+ const keepEmb = working.get(keepId);
102
+ const dropEmb = working.get(dropId);
103
+ const merged = new Float32Array(keepEmb.length);
104
+ for (let k = 0; k < keepEmb.length; k++) {
105
+ merged[k] = (keepEmb[k] + dropEmb[k]) / 2;
106
+ }
107
+ working.set(keepId, merged);
108
+ working.delete(dropId);
109
+ // Record the rename. Chase any existing mappings so the final map is
110
+ // transitively resolved.
111
+ renameMap.set(dropId, keepId);
112
+ // Resolve transitive renames: if dropId was itself a canonical target of
113
+ // an earlier merge, update those entries to point to keepId.
114
+ for (const [old, target] of renameMap) {
115
+ if (target === dropId) {
116
+ renameMap.set(old, keepId);
117
+ }
118
+ }
119
+ }
120
+ return renameMap;
121
+ }
122
+ }
123
+ //# sourceMappingURL=ClusteringStrategy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClusteringStrategy.js","sourceRoot":"","sources":["../src/ClusteringStrategy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,45 @@
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
+ import type { IDiarizationEngine, IDiarizationSession, DiarizationConfig } from './types.js';
15
+ /**
16
+ * Factory for diarization sessions.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const engine = new DiarizationEngine();
21
+ *
22
+ * // Local mode (default)
23
+ * const session = engine.startSession();
24
+ * session.on('speaker_identified', ({ speakerId }) => console.log(speakerId));
25
+ *
26
+ * // Provider mode
27
+ * const providerSession = engine.startSession({ backend: 'provider' });
28
+ * sttSession.on('transcript', (e) => providerSession.labelTranscript(e));
29
+ * ```
30
+ */
31
+ export declare class DiarizationEngine implements IDiarizationEngine {
32
+ /**
33
+ * Create and return a new {@link IDiarizationSession}.
34
+ *
35
+ * The backend is selected based on `config.backend`:
36
+ * - `'provider'` — use {@link ProviderDiarizationBackend}
37
+ * - `'local'` (default) — use {@link LocalDiarizationBackend} with a fresh
38
+ * {@link SpeakerEmbeddingCache} and {@link SlidingWindowExtractor}
39
+ *
40
+ * @param config - Optional session configuration.
41
+ * @returns A ready-to-use {@link DiarizationSession}.
42
+ */
43
+ startSession(config?: DiarizationConfig): IDiarizationSession;
44
+ }
45
+ //# sourceMappingURL=DiarizationEngine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DiarizationEngine.d.ts","sourceRoot":"","sources":["../src/DiarizationEngine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAoB7F;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,iBAAkB,YAAW,kBAAkB;IAK1D;;;;;;;;;;OAUG;IACH,YAAY,CAAC,MAAM,GAAE,iBAAsB,GAAG,mBAAmB;CA2BlE"}
@@ -0,0 +1,84 @@
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
+ import { DiarizationSession } from './DiarizationSession.js';
15
+ import { SpeakerEmbeddingCache } from './SpeakerEmbeddingCache.js';
16
+ import { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
17
+ import { LocalDiarizationBackend } from './LocalDiarizationBackend.js';
18
+ import { ProviderDiarizationBackend } from './ProviderDiarizationBackend.js';
19
+ // ---------------------------------------------------------------------------
20
+ // Default values
21
+ // ---------------------------------------------------------------------------
22
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.7;
23
+ const DEFAULT_CHUNK_SIZE_MS = 1500;
24
+ const DEFAULT_OVERLAP_MS = 500;
25
+ const DEFAULT_SAMPLE_RATE = 16_000;
26
+ // ---------------------------------------------------------------------------
27
+ // Main class
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Factory for diarization sessions.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const engine = new DiarizationEngine();
35
+ *
36
+ * // Local mode (default)
37
+ * const session = engine.startSession();
38
+ * session.on('speaker_identified', ({ speakerId }) => console.log(speakerId));
39
+ *
40
+ * // Provider mode
41
+ * const providerSession = engine.startSession({ backend: 'provider' });
42
+ * sttSession.on('transcript', (e) => providerSession.labelTranscript(e));
43
+ * ```
44
+ */
45
+ export class DiarizationEngine {
46
+ // -------------------------------------------------------------------------
47
+ // IDiarizationEngine implementation
48
+ // -------------------------------------------------------------------------
49
+ /**
50
+ * Create and return a new {@link IDiarizationSession}.
51
+ *
52
+ * The backend is selected based on `config.backend`:
53
+ * - `'provider'` — use {@link ProviderDiarizationBackend}
54
+ * - `'local'` (default) — use {@link LocalDiarizationBackend} with a fresh
55
+ * {@link SpeakerEmbeddingCache} and {@link SlidingWindowExtractor}
56
+ *
57
+ * @param config - Optional session configuration.
58
+ * @returns A ready-to-use {@link DiarizationSession}.
59
+ */
60
+ startSession(config = {}) {
61
+ const backend = config.backend ?? 'local';
62
+ if (backend === 'provider') {
63
+ return new DiarizationSession({
64
+ kind: 'provider',
65
+ backend: new ProviderDiarizationBackend(),
66
+ });
67
+ }
68
+ // Local mode — wire up the full pipeline.
69
+ const threshold = config.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
70
+ const chunkSizeMs = config.chunkSizeMs ?? DEFAULT_CHUNK_SIZE_MS;
71
+ const overlapMs = config.overlapMs ?? DEFAULT_OVERLAP_MS;
72
+ const sampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
73
+ const cache = new SpeakerEmbeddingCache(threshold);
74
+ const extractor = new SlidingWindowExtractor(chunkSizeMs, overlapMs, sampleRate);
75
+ const localBackend = new LocalDiarizationBackend(cache, extractor);
76
+ return new DiarizationSession({
77
+ kind: 'local',
78
+ backend: localBackend,
79
+ cache,
80
+ extractor,
81
+ });
82
+ }
83
+ }
84
+ //# sourceMappingURL=DiarizationEngine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DiarizationEngine.js","sourceRoot":"","sources":["../src/DiarizationEngine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAE7E,8EAA8E;AAC9E,iBAAiB;AACjB,8EAA8E;AAE9E,MAAM,4BAA4B,GAAG,GAAG,CAAC;AACzC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AACnC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAC/B,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAEnC,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,iBAAiB;IAC5B,4EAA4E;IAC5E,oCAAoC;IACpC,4EAA4E;IAE5E;;;;;;;;;;OAUG;IACH,YAAY,CAAC,SAA4B,EAAE;QACzC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,IAAI,OAAO,CAAC;QAE1C,IAAI,OAAO,KAAK,UAAU,EAAE,CAAC;YAC3B,OAAO,IAAI,kBAAkB,CAAC;gBAC5B,IAAI,EAAE,UAAU;gBAChB,OAAO,EAAE,IAAI,0BAA0B,EAAE;aAC1C,CAAC,CAAC;QACL,CAAC;QAED,0CAA0C;QAC1C,MAAM,SAAS,GAAG,MAAM,CAAC,mBAAmB,IAAI,4BAA4B,CAAC;QAC7E,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,qBAAqB,CAAC;QAChE,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,kBAAkB,CAAC;QACzD,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,mBAAmB,CAAC;QAE5D,MAAM,KAAK,GAAG,IAAI,qBAAqB,CAAC,SAAS,CAAC,CAAC;QACnD,MAAM,SAAS,GAAG,IAAI,sBAAsB,CAAC,WAAW,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;QACjF,MAAM,YAAY,GAAG,IAAI,uBAAuB,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;QAEnE,OAAO,IAAI,kBAAkB,CAAC;YAC5B,IAAI,EAAE,OAAO;YACb,OAAO,EAAE,YAAY;YACrB,KAAK;YACL,SAAS;SACV,CAAC,CAAC;IACL,CAAC;CACF"}
@@ -0,0 +1,90 @@
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
+ import { EventEmitter } from 'node:events';
13
+ import type { IDiarizationSession, AudioFrame, TranscriptEvent, DiarizedSegment } from './types.js';
14
+ import type { ProviderDiarizationBackend } from './ProviderDiarizationBackend.js';
15
+ import type { LocalDiarizationBackend } from './LocalDiarizationBackend.js';
16
+ import type { SpeakerEmbeddingCache } from './SpeakerEmbeddingCache.js';
17
+ import type { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
18
+ /** Provider-delegated backend descriptor. */
19
+ interface ProviderBackendDescriptor {
20
+ kind: 'provider';
21
+ backend: ProviderDiarizationBackend;
22
+ }
23
+ /** Local-clustering backend descriptor. */
24
+ interface LocalBackendDescriptor {
25
+ kind: 'local';
26
+ backend: LocalDiarizationBackend;
27
+ cache: SpeakerEmbeddingCache;
28
+ extractor: SlidingWindowExtractor;
29
+ }
30
+ type BackendDescriptor = ProviderBackendDescriptor | LocalBackendDescriptor;
31
+ /**
32
+ * A live diarization session.
33
+ *
34
+ * Create instances via {@link DiarizationEngine.startSession} rather than
35
+ * constructing directly.
36
+ *
37
+ * ### Events
38
+ * - `speaker_identified` — payload: {@link SpeakerIdentified}
39
+ * - `segment_ready` — payload: {@link DiarizedSegment}
40
+ * - `error` — payload: `Error`
41
+ * - `close` — no payload
42
+ */
43
+ export declare class DiarizationSession extends EventEmitter implements IDiarizationSession {
44
+ private readonly descriptor;
45
+ private closed;
46
+ /** Most recently identified speaker (used for transcript labelling in local mode). */
47
+ private lastSpeakerId;
48
+ /**
49
+ * @param descriptor - Resolved backend descriptor.
50
+ */
51
+ constructor(descriptor: BackendDescriptor);
52
+ /**
53
+ * Push a raw audio frame into the pipeline.
54
+ *
55
+ * In local mode the frame is forwarded to the sliding-window extractor.
56
+ * In provider mode this is a no-op.
57
+ *
58
+ * @param frame - Audio frame to process.
59
+ */
60
+ pushAudio(frame: AudioFrame): void;
61
+ /**
62
+ * Attach speaker labels to a transcript event and emit a segment.
63
+ *
64
+ * In provider mode, word-level speaker labels are extracted directly. In
65
+ * local mode, the most recently identified speaker is used.
66
+ *
67
+ * @param event - Transcript event from the STT provider.
68
+ * @returns The labelled {@link DiarizedSegment}, or `null` if no speaker
69
+ * information is available yet.
70
+ */
71
+ labelTranscript(event: TranscriptEvent): DiarizedSegment | null;
72
+ /**
73
+ * Pre-register a known speaker voiceprint.
74
+ *
75
+ * Only has effect in local mode. In provider mode, speaker IDs come
76
+ * directly from the STT provider and this call is a no-op.
77
+ *
78
+ * @param id - Human-readable speaker name.
79
+ * @param voiceprint - Reference embedding for this speaker.
80
+ */
81
+ enrollSpeaker(id: string, voiceprint: Float32Array): void;
82
+ /**
83
+ * Terminate the session and release all resources.
84
+ *
85
+ * After `close()` no further events are emitted.
86
+ */
87
+ close(): void;
88
+ }
89
+ export {};
90
+ //# sourceMappingURL=DiarizationSession.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DiarizationSession.d.ts","sourceRoot":"","sources":["../src/DiarizationSession.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAqB,MAAM,YAAY,CAAC;AACvH,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAM1E,6CAA6C;AAC7C,UAAU,yBAAyB;IACjC,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,EAAE,0BAA0B,CAAC;CACrC;AAED,2CAA2C;AAC3C,UAAU,sBAAsB;IAC9B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,uBAAuB,CAAC;IACjC,KAAK,EAAE,qBAAqB,CAAC;IAC7B,SAAS,EAAE,sBAAsB,CAAC;CACnC;AAED,KAAK,iBAAiB,GAAG,yBAAyB,GAAG,sBAAsB,CAAC;AAM5E;;;;;;;;;;;GAWG;AACH,qBAAa,kBAAmB,SAAQ,YAAa,YAAW,mBAAmB;IAiBrE,OAAO,CAAC,QAAQ,CAAC,UAAU;IAZvC,OAAO,CAAC,MAAM,CAAS;IAEvB,sFAAsF;IACtF,OAAO,CAAC,aAAa,CAAuB;IAM5C;;OAEG;gBAC0B,UAAU,EAAE,iBAAiB;IAkB1D;;;;;;;OAOG;IACH,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IASlC;;;;;;;;;OASG;IACH,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,eAAe,GAAG,IAAI;IA2B/D;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,YAAY,GAAG,IAAI;IAOzD;;;;OAIG;IACH,KAAK,IAAI,IAAI;CAYd"}
@@ -0,0 +1,139 @@
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
+ import { EventEmitter } from 'node:events';
13
+ // ---------------------------------------------------------------------------
14
+ // Main class
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * A live diarization session.
18
+ *
19
+ * Create instances via {@link DiarizationEngine.startSession} rather than
20
+ * constructing directly.
21
+ *
22
+ * ### Events
23
+ * - `speaker_identified` — payload: {@link SpeakerIdentified}
24
+ * - `segment_ready` — payload: {@link DiarizedSegment}
25
+ * - `error` — payload: `Error`
26
+ * - `close` — no payload
27
+ */
28
+ export class DiarizationSession extends EventEmitter {
29
+ descriptor;
30
+ // -------------------------------------------------------------------------
31
+ // Private state
32
+ // -------------------------------------------------------------------------
33
+ closed = false;
34
+ /** Most recently identified speaker (used for transcript labelling in local mode). */
35
+ lastSpeakerId = null;
36
+ // -------------------------------------------------------------------------
37
+ // Constructor
38
+ // -------------------------------------------------------------------------
39
+ /**
40
+ * @param descriptor - Resolved backend descriptor.
41
+ */
42
+ constructor(descriptor) {
43
+ super();
44
+ this.descriptor = descriptor;
45
+ if (descriptor.kind === 'local') {
46
+ // Forward speaker_identified events from the local backend.
47
+ descriptor.backend.on('speaker_identified', (payload) => {
48
+ this.lastSpeakerId = payload.speakerId;
49
+ this.emit('speaker_identified', payload);
50
+ });
51
+ descriptor.backend.start();
52
+ }
53
+ }
54
+ // -------------------------------------------------------------------------
55
+ // IDiarizationSession implementation
56
+ // -------------------------------------------------------------------------
57
+ /**
58
+ * Push a raw audio frame into the pipeline.
59
+ *
60
+ * In local mode the frame is forwarded to the sliding-window extractor.
61
+ * In provider mode this is a no-op.
62
+ *
63
+ * @param frame - Audio frame to process.
64
+ */
65
+ pushAudio(frame) {
66
+ if (this.closed)
67
+ return;
68
+ if (this.descriptor.kind === 'local') {
69
+ this.descriptor.extractor.pushAudio(frame);
70
+ }
71
+ // Provider mode: audio processing happens on the provider side.
72
+ }
73
+ /**
74
+ * Attach speaker labels to a transcript event and emit a segment.
75
+ *
76
+ * In provider mode, word-level speaker labels are extracted directly. In
77
+ * local mode, the most recently identified speaker is used.
78
+ *
79
+ * @param event - Transcript event from the STT provider.
80
+ * @returns The labelled {@link DiarizedSegment}, or `null` if no speaker
81
+ * information is available yet.
82
+ */
83
+ labelTranscript(event) {
84
+ if (this.closed)
85
+ return null;
86
+ let segment = null;
87
+ if (this.descriptor.kind === 'provider') {
88
+ segment = this.descriptor.backend.pushTranscriptEvent(event);
89
+ }
90
+ else {
91
+ // Local mode — use the last identified speaker.
92
+ if (this.lastSpeakerId !== null) {
93
+ segment = {
94
+ speakerId: this.lastSpeakerId,
95
+ text: event.text,
96
+ startMs: event.timestamp,
97
+ endMs: event.timestamp + event.text.length * 50, // rough estimate
98
+ confidence: 0.7,
99
+ };
100
+ }
101
+ }
102
+ if (segment) {
103
+ this.emit('segment_ready', segment);
104
+ }
105
+ return segment;
106
+ }
107
+ /**
108
+ * Pre-register a known speaker voiceprint.
109
+ *
110
+ * Only has effect in local mode. In provider mode, speaker IDs come
111
+ * directly from the STT provider and this call is a no-op.
112
+ *
113
+ * @param id - Human-readable speaker name.
114
+ * @param voiceprint - Reference embedding for this speaker.
115
+ */
116
+ enrollSpeaker(id, voiceprint) {
117
+ if (this.descriptor.kind === 'local') {
118
+ this.descriptor.cache.enrollSpeaker(id, voiceprint);
119
+ }
120
+ // Provider mode: enrolment is handled by the provider; ignore here.
121
+ }
122
+ /**
123
+ * Terminate the session and release all resources.
124
+ *
125
+ * After `close()` no further events are emitted.
126
+ */
127
+ close() {
128
+ if (this.closed)
129
+ return;
130
+ this.closed = true;
131
+ if (this.descriptor.kind === 'local') {
132
+ this.descriptor.backend.stop();
133
+ this.descriptor.extractor.flush();
134
+ }
135
+ this.emit('close');
136
+ this.removeAllListeners();
137
+ }
138
+ }
139
+ //# sourceMappingURL=DiarizationSession.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DiarizationSession.js","sourceRoot":"","sources":["../src/DiarizationSession.ts"],"names":[],"mappings":"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"}