@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.
- package/LICENSE +23 -0
- package/SKILL.md +71 -0
- package/dist/ClusteringStrategy.d.ts +56 -0
- package/dist/ClusteringStrategy.d.ts.map +1 -0
- package/dist/ClusteringStrategy.js +123 -0
- package/dist/ClusteringStrategy.js.map +1 -0
- package/dist/DiarizationEngine.d.ts +45 -0
- package/dist/DiarizationEngine.d.ts.map +1 -0
- package/dist/DiarizationEngine.js +84 -0
- package/dist/DiarizationEngine.js.map +1 -0
- package/dist/DiarizationSession.d.ts +90 -0
- package/dist/DiarizationSession.d.ts.map +1 -0
- package/dist/DiarizationSession.js +139 -0
- package/dist/DiarizationSession.js.map +1 -0
- package/dist/LocalDiarizationBackend.d.ts +90 -0
- package/dist/LocalDiarizationBackend.d.ts.map +1 -0
- package/dist/LocalDiarizationBackend.js +177 -0
- package/dist/LocalDiarizationBackend.js.map +1 -0
- package/dist/ProviderDiarizationBackend.d.ts +54 -0
- package/dist/ProviderDiarizationBackend.d.ts.map +1 -0
- package/dist/ProviderDiarizationBackend.js +101 -0
- package/dist/ProviderDiarizationBackend.js.map +1 -0
- package/dist/SlidingWindowExtractor.d.ts +85 -0
- package/dist/SlidingWindowExtractor.d.ts.map +1 -0
- package/dist/SlidingWindowExtractor.js +140 -0
- package/dist/SlidingWindowExtractor.js.map +1 -0
- package/dist/SpeakerEmbeddingCache.d.ts +117 -0
- package/dist/SpeakerEmbeddingCache.d.ts.map +1 -0
- package/dist/SpeakerEmbeddingCache.js +193 -0
- package/dist/SpeakerEmbeddingCache.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +177 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/manifest.json +8 -0
- package/package.json +48 -0
- package/src/ClusteringStrategy.ts +139 -0
- package/src/DiarizationEngine.ts +94 -0
- package/src/DiarizationSession.ts +178 -0
- package/src/LocalDiarizationBackend.ts +201 -0
- package/src/ProviderDiarizationBackend.ts +119 -0
- package/src/SlidingWindowExtractor.ts +174 -0
- package/src/SpeakerEmbeddingCache.ts +228 -0
- package/src/index.ts +105 -0
- 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
|
+
}
|