@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ProviderDiarizationBackend.ts
|
|
3
|
+
* @description Thin adapter that extracts speaker labels from STT provider
|
|
4
|
+
* transcript events containing word-level speaker annotations.
|
|
5
|
+
*
|
|
6
|
+
* Many cloud STT providers (Deepgram, AssemblyAI, …) support native
|
|
7
|
+
* diarization that produces a `speaker` label on each word in the transcript.
|
|
8
|
+
* This backend consumes those events and re-emits them as
|
|
9
|
+
* {@link DiarizedSegment} objects without any additional compute.
|
|
10
|
+
*
|
|
11
|
+
* @module diarization/ProviderDiarizationBackend
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TranscriptEvent, DiarizedSegment, TranscriptWord } from './types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Main class
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts speaker labels from word-level STT results.
|
|
22
|
+
*
|
|
23
|
+
* Stateless — each call to {@link pushTranscriptEvent} is independent. The
|
|
24
|
+
* caller is responsible for any session-level bookkeeping.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const backend = new ProviderDiarizationBackend();
|
|
29
|
+
* sttSession.on('transcript', (event) => {
|
|
30
|
+
* const segment = backend.pushTranscriptEvent(event);
|
|
31
|
+
* if (segment) console.log(segment.speakerId, segment.text);
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export class ProviderDiarizationBackend {
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
// Public API
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Process a transcript event and extract the dominant speaker label.
|
|
42
|
+
*
|
|
43
|
+
* The dominant speaker is determined by majority vote across the word-level
|
|
44
|
+
* `speaker` annotations. If no words carry speaker labels, `null` is
|
|
45
|
+
* returned.
|
|
46
|
+
*
|
|
47
|
+
* For final transcripts (`isFinal: true`) the returned segment covers the
|
|
48
|
+
* full utterance. For interim transcripts the segment is a best-effort
|
|
49
|
+
* approximation based on the current words.
|
|
50
|
+
*
|
|
51
|
+
* @param event - Transcript event from an STT provider session.
|
|
52
|
+
* @returns A {@link DiarizedSegment} if speaker information is available,
|
|
53
|
+
* or `null` if the event lacks word-level speaker labels.
|
|
54
|
+
*/
|
|
55
|
+
pushTranscriptEvent(event: TranscriptEvent): DiarizedSegment | null {
|
|
56
|
+
const words = event.words;
|
|
57
|
+
|
|
58
|
+
if (!words || words.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Collect all words that have a speaker label.
|
|
63
|
+
const labelledWords = words.filter((w) => w.speaker !== undefined && w.speaker !== '');
|
|
64
|
+
|
|
65
|
+
if (labelledWords.length === 0) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Majority-vote for the dominant speaker in this transcript event.
|
|
70
|
+
const speakerId = this.dominantSpeaker(labelledWords);
|
|
71
|
+
|
|
72
|
+
// Derive segment boundaries from word timestamps (seconds → milliseconds).
|
|
73
|
+
const firstWord = labelledWords[0]!;
|
|
74
|
+
const lastWord = labelledWords[labelledWords.length - 1]!;
|
|
75
|
+
|
|
76
|
+
// Average per-word confidence (default 1 if not present).
|
|
77
|
+
const avgConfidence =
|
|
78
|
+
labelledWords.reduce((sum, w) => sum + (w.confidence ?? 1), 0) / labelledWords.length;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
speakerId,
|
|
82
|
+
text: event.text,
|
|
83
|
+
startMs: Math.round(firstWord.start * 1000),
|
|
84
|
+
endMs: Math.round(lastWord.end * 1000),
|
|
85
|
+
confidence: avgConfidence,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
// Internal helpers
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Determine the most-frequently-occurring speaker label among a set of words.
|
|
95
|
+
*
|
|
96
|
+
* @param words - Words that all have a defined `speaker` property.
|
|
97
|
+
* @returns The dominant `speaker` label.
|
|
98
|
+
*/
|
|
99
|
+
private dominantSpeaker(words: TranscriptWord[]): string {
|
|
100
|
+
const counts = new Map<string, number>();
|
|
101
|
+
|
|
102
|
+
for (const word of words) {
|
|
103
|
+
const label = word.speaker!;
|
|
104
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let best = words[0]!.speaker!;
|
|
108
|
+
let bestCount = 0;
|
|
109
|
+
|
|
110
|
+
for (const [label, count] of counts) {
|
|
111
|
+
if (count > bestCount) {
|
|
112
|
+
bestCount = count;
|
|
113
|
+
best = label;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return best;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SlidingWindowExtractor.ts
|
|
3
|
+
* @description Ring buffer that accumulates audio frames into fixed-size chunks
|
|
4
|
+
* with configurable overlap, tuned for diarization (1.5 s chunks, 0.5 s overlap).
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the design of `SlidingWindowBuffer` from the Whisper STT pack but
|
|
7
|
+
* operates on {@link AudioFrame} objects rather than raw sample arrays, and
|
|
8
|
+
* uses millisecond-based chunk/overlap sizes for a friendlier API.
|
|
9
|
+
*
|
|
10
|
+
* @module diarization/SlidingWindowExtractor
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import type { AudioFrame } from './types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Events interface
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Event map for {@link SlidingWindowExtractor}. */
|
|
21
|
+
export interface SlidingWindowExtractorEvents {
|
|
22
|
+
/** Emitted when a complete chunk of audio samples is ready. */
|
|
23
|
+
chunk_ready: [chunk: Float32Array];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Main class
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sliding-window audio extractor for the diarization pipeline.
|
|
32
|
+
*
|
|
33
|
+
* Accepts {@link AudioFrame} objects pushed by the voice pipeline, accumulates
|
|
34
|
+
* samples in an internal ring buffer, and emits `'chunk_ready'` events when a
|
|
35
|
+
* full chunk is available. The last `overlapSamples` of each chunk are kept
|
|
36
|
+
* as context at the head of the next chunk.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const extractor = new SlidingWindowExtractor(1500, 500, 16000);
|
|
41
|
+
* extractor.on('chunk_ready', (chunk) => processChunk(chunk));
|
|
42
|
+
* microphone.on('frame', (f) => extractor.pushAudio(f));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class SlidingWindowExtractor extends EventEmitter {
|
|
46
|
+
// -------------------------------------------------------------------------
|
|
47
|
+
// Private state
|
|
48
|
+
// -------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Internal sample store. Sized to {@link chunkSizeSamples}. */
|
|
51
|
+
private buffer: Float32Array;
|
|
52
|
+
|
|
53
|
+
/** Current write position within {@link buffer}. */
|
|
54
|
+
private writePos = 0;
|
|
55
|
+
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Computed dimensions (from ms → samples)
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Number of samples per emitted chunk. */
|
|
61
|
+
private readonly chunkSizeSamples: number;
|
|
62
|
+
|
|
63
|
+
/** Number of overlap samples carried forward to the next chunk. */
|
|
64
|
+
private readonly overlapSamples: number;
|
|
65
|
+
|
|
66
|
+
// -------------------------------------------------------------------------
|
|
67
|
+
// Constructor
|
|
68
|
+
// -------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param chunkSizeMs - Duration of each emitted chunk in milliseconds.
|
|
72
|
+
* @defaultValue 1500
|
|
73
|
+
* @param overlapMs - Overlap between consecutive chunks in milliseconds.
|
|
74
|
+
* Must be less than `chunkSizeMs`.
|
|
75
|
+
* @defaultValue 500
|
|
76
|
+
* @param sampleRate - Expected audio sample rate in Hz.
|
|
77
|
+
* @defaultValue 16000
|
|
78
|
+
*/
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly chunkSizeMs: number = 1500,
|
|
81
|
+
private readonly overlapMs: number = 500,
|
|
82
|
+
private readonly sampleRate: number = 16_000,
|
|
83
|
+
) {
|
|
84
|
+
super();
|
|
85
|
+
|
|
86
|
+
this.chunkSizeSamples = Math.round((chunkSizeMs / 1000) * sampleRate);
|
|
87
|
+
this.overlapSamples = Math.round((overlapMs / 1000) * sampleRate);
|
|
88
|
+
|
|
89
|
+
if (this.overlapSamples >= this.chunkSizeSamples) {
|
|
90
|
+
throw new RangeError(
|
|
91
|
+
`overlapMs (${overlapMs}) must result in fewer samples than chunkSizeMs (${chunkSizeMs})`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.buffer = new Float32Array(this.chunkSizeSamples);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
// Public API
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Push an audio frame into the extractor.
|
|
104
|
+
*
|
|
105
|
+
* Samples are appended to the internal ring buffer. When the buffer fills
|
|
106
|
+
* to `chunkSizeSamples`, a `'chunk_ready'` event is emitted and the overlap
|
|
107
|
+
* region is copied to the head of the buffer for the next chunk.
|
|
108
|
+
*
|
|
109
|
+
* @param frame - Audio frame to append.
|
|
110
|
+
*/
|
|
111
|
+
pushAudio(frame: AudioFrame): void {
|
|
112
|
+
this.pushSamples(frame.samples);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Flush any remaining samples as a final partial chunk.
|
|
117
|
+
*
|
|
118
|
+
* If the buffer is empty this is a no-op.
|
|
119
|
+
*/
|
|
120
|
+
flush(): void {
|
|
121
|
+
if (this.writePos === 0) return;
|
|
122
|
+
this.emit('chunk_ready', this.buffer.slice(0, this.writePos));
|
|
123
|
+
this.reset();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Clear the buffer without emitting a chunk.
|
|
128
|
+
*/
|
|
129
|
+
reset(): void {
|
|
130
|
+
this.buffer = new Float32Array(this.chunkSizeSamples);
|
|
131
|
+
this.writePos = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Accessors
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** Number of samples currently held in the buffer. */
|
|
139
|
+
get bufferedSamples(): number {
|
|
140
|
+
return this.writePos;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
// Internal helpers
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Append raw PCM samples and emit chunks as they fill.
|
|
149
|
+
*
|
|
150
|
+
* @param samples - Float32 PCM data to append.
|
|
151
|
+
*/
|
|
152
|
+
private pushSamples(samples: Float32Array): void {
|
|
153
|
+
let srcOffset = 0;
|
|
154
|
+
|
|
155
|
+
while (srcOffset < samples.length) {
|
|
156
|
+
const spaceLeft = this.chunkSizeSamples - this.writePos;
|
|
157
|
+
const copyCount = Math.min(spaceLeft, samples.length - srcOffset);
|
|
158
|
+
|
|
159
|
+
this.buffer.set(samples.subarray(srcOffset, srcOffset + copyCount), this.writePos);
|
|
160
|
+
this.writePos += copyCount;
|
|
161
|
+
srcOffset += copyCount;
|
|
162
|
+
|
|
163
|
+
if (this.writePos >= this.chunkSizeSamples) {
|
|
164
|
+
// Emit a copy — the buffer is reused for the next chunk.
|
|
165
|
+
this.emit('chunk_ready', this.buffer.slice());
|
|
166
|
+
|
|
167
|
+
// Copy the overlap region to the start of the buffer.
|
|
168
|
+
const overlapStart = this.chunkSizeSamples - this.overlapSamples;
|
|
169
|
+
this.buffer.copyWithin(0, overlapStart, this.chunkSizeSamples);
|
|
170
|
+
this.writePos = this.overlapSamples;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SpeakerEmbeddingCache.ts
|
|
3
|
+
* @description Stores and matches speaker voiceprints using cosine similarity.
|
|
4
|
+
*
|
|
5
|
+
* Each speaker is represented as a running centroid — the mean of all
|
|
6
|
+
* embeddings observed so far. New audio embeddings are matched against
|
|
7
|
+
* existing centroids; if the best match exceeds the similarity threshold the
|
|
8
|
+
* frame is attributed to that speaker and the centroid is updated. Otherwise
|
|
9
|
+
* a new `Speaker_N` identity is created.
|
|
10
|
+
*
|
|
11
|
+
* @module diarization/SpeakerEmbeddingCache
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Internal types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Running centroid entry stored per speaker. */
|
|
19
|
+
interface CentroidEntry {
|
|
20
|
+
/** Current mean embedding vector. */
|
|
21
|
+
embedding: Float32Array;
|
|
22
|
+
/** Number of frames that have contributed to this centroid. */
|
|
23
|
+
count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute the cosine similarity between two embedding vectors.
|
|
32
|
+
*
|
|
33
|
+
* Returns a value in the range `[-1, 1]`. A small epsilon is added to the
|
|
34
|
+
* denominator to prevent division-by-zero on silent/zero frames.
|
|
35
|
+
*
|
|
36
|
+
* @param a - First embedding vector.
|
|
37
|
+
* @param b - Second embedding vector.
|
|
38
|
+
* @returns Cosine similarity.
|
|
39
|
+
*/
|
|
40
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
41
|
+
let dot = 0;
|
|
42
|
+
let magA = 0;
|
|
43
|
+
let magB = 0;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < a.length; i++) {
|
|
46
|
+
dot += a[i]! * b[i]!;
|
|
47
|
+
magA += (a[i]!) ** 2;
|
|
48
|
+
magB += (b[i]!) ** 2;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB) + 1e-8);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Main class
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* In-memory cache of speaker voiceprint centroids.
|
|
60
|
+
*
|
|
61
|
+
* Centroids are updated with an online running-average as new embeddings
|
|
62
|
+
* arrive, so memory usage is O(speakers × embedding dimensions) regardless of
|
|
63
|
+
* the number of frames observed.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* const cache = new SpeakerEmbeddingCache(0.7);
|
|
68
|
+
* const { speakerId, isNew } = cache.getOrCreateSpeaker(embedding);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export class SpeakerEmbeddingCache {
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
// Private state
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Centroid map: `speakerId → { embedding, count }`.
|
|
78
|
+
*
|
|
79
|
+
* Access via {@link centroids} for testing/introspection.
|
|
80
|
+
*/
|
|
81
|
+
private readonly _centroids = new Map<string, CentroidEntry>();
|
|
82
|
+
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
// Constructor
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param threshold - Minimum cosine similarity required to attribute a new
|
|
89
|
+
* embedding to an existing speaker. Embeddings below this threshold
|
|
90
|
+
* create a new speaker identity.
|
|
91
|
+
* @defaultValue 0.7
|
|
92
|
+
*/
|
|
93
|
+
constructor(private readonly threshold: number = 0.7) {}
|
|
94
|
+
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// Public API
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update the running centroid for `speakerId` with a new embedding.
|
|
101
|
+
*
|
|
102
|
+
* The centroid is recalculated as an online running average:
|
|
103
|
+
* `new_mean = old_mean + (x - old_mean) / (count + 1)`.
|
|
104
|
+
*
|
|
105
|
+
* @param speakerId - Speaker identity to update.
|
|
106
|
+
* @param embedding - New embedding vector to incorporate.
|
|
107
|
+
*/
|
|
108
|
+
addEmbedding(speakerId: string, embedding: Float32Array): void {
|
|
109
|
+
const existing = this._centroids.get(speakerId);
|
|
110
|
+
|
|
111
|
+
if (!existing) {
|
|
112
|
+
// First frame — store a copy of the embedding directly.
|
|
113
|
+
this._centroids.set(speakerId, {
|
|
114
|
+
embedding: embedding.slice(),
|
|
115
|
+
count: 1,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Online running average: mean_n = mean_{n-1} + (x - mean_{n-1}) / n
|
|
121
|
+
const newCount = existing.count + 1;
|
|
122
|
+
const newEmbedding = new Float32Array(existing.embedding.length);
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < existing.embedding.length; i++) {
|
|
125
|
+
newEmbedding[i] = existing.embedding[i]! + (embedding[i]! - existing.embedding[i]!) / newCount;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this._centroids.set(speakerId, { embedding: newEmbedding, count: newCount });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find the existing speaker whose centroid is most similar to `embedding`.
|
|
133
|
+
*
|
|
134
|
+
* Returns `null` if no speaker exceeds the similarity threshold or the cache
|
|
135
|
+
* is empty.
|
|
136
|
+
*
|
|
137
|
+
* @param embedding - Query embedding vector.
|
|
138
|
+
* @returns Best match above threshold, or `null`.
|
|
139
|
+
*/
|
|
140
|
+
findClosestSpeaker(embedding: Float32Array): { speakerId: string; similarity: number } | null {
|
|
141
|
+
let bestId: string | null = null;
|
|
142
|
+
let bestSim = -Infinity;
|
|
143
|
+
|
|
144
|
+
for (const [id, entry] of this._centroids) {
|
|
145
|
+
const sim = cosineSimilarity(embedding, entry.embedding);
|
|
146
|
+
if (sim > bestSim) {
|
|
147
|
+
bestSim = sim;
|
|
148
|
+
bestId = id;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (bestId === null || bestSim < this.threshold) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { speakerId: bestId, similarity: bestSim };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Look up or create a speaker identity for `embedding`.
|
|
161
|
+
*
|
|
162
|
+
* If a match above the threshold is found, the centroid is updated and the
|
|
163
|
+
* existing ID is returned. Otherwise a new `Speaker_N` identity is
|
|
164
|
+
* registered.
|
|
165
|
+
*
|
|
166
|
+
* @param embedding - Audio embedding to resolve.
|
|
167
|
+
* @returns Speaker ID and whether a new identity was created.
|
|
168
|
+
*/
|
|
169
|
+
getOrCreateSpeaker(embedding: Float32Array): { speakerId: string; isNew: boolean } {
|
|
170
|
+
const match = this.findClosestSpeaker(embedding);
|
|
171
|
+
|
|
172
|
+
if (match) {
|
|
173
|
+
this.addEmbedding(match.speakerId, embedding);
|
|
174
|
+
return { speakerId: match.speakerId, isNew: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const newId = `Speaker_${this._centroids.size}`;
|
|
178
|
+
this.addEmbedding(newId, embedding);
|
|
179
|
+
return { speakerId: newId, isNew: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Pre-register a known speaker voiceprint, overwriting any existing centroid
|
|
184
|
+
* for this ID.
|
|
185
|
+
*
|
|
186
|
+
* Use this to seed the cache with enrolment recordings before a session
|
|
187
|
+
* begins so that known participants are matched by name rather than the
|
|
188
|
+
* auto-generated `Speaker_N` label.
|
|
189
|
+
*
|
|
190
|
+
* @param id - Human-readable speaker identifier.
|
|
191
|
+
* @param voiceprint - Reference embedding for this speaker.
|
|
192
|
+
*/
|
|
193
|
+
enrollSpeaker(id: string, voiceprint: Float32Array): void {
|
|
194
|
+
this._centroids.set(id, { embedding: voiceprint.slice(), count: 1 });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
// Accessors (useful for testing and ClusteringStrategy)
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read-only view of the internal centroid map.
|
|
203
|
+
*
|
|
204
|
+
* Returns a snapshot — modifications to the returned map do not affect the
|
|
205
|
+
* cache.
|
|
206
|
+
*/
|
|
207
|
+
get centroids(): Map<string, Float32Array> {
|
|
208
|
+
const result = new Map<string, Float32Array>();
|
|
209
|
+
for (const [id, entry] of this._centroids) {
|
|
210
|
+
result.set(id, entry.embedding.slice());
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Number of distinct speaker identities currently tracked. */
|
|
216
|
+
get size(): number {
|
|
217
|
+
return this._centroids.size;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Remove all centroids from the cache.
|
|
222
|
+
*
|
|
223
|
+
* Useful for resetting state between test cases.
|
|
224
|
+
*/
|
|
225
|
+
clear(): void {
|
|
226
|
+
this._centroids.clear();
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.ts
|
|
3
|
+
* @description Pack factory for the Diarization extension pack.
|
|
4
|
+
*
|
|
5
|
+
* ### Usage (direct)
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { createDiarizationEngine } from '@framers/agentos-ext-diarization';
|
|
8
|
+
*
|
|
9
|
+
* const engine = createDiarizationEngine();
|
|
10
|
+
* const session = engine.startSession({ backend: 'local' });
|
|
11
|
+
* session.on('speaker_identified', ({ speakerId }) => console.log(speakerId));
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ### Usage (manifest-driven)
|
|
15
|
+
* ```json
|
|
16
|
+
* { "packs": [{ "module": "@framers/agentos-ext-diarization" }] }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module diarization
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { DiarizationEngine } from './DiarizationEngine.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Local types (avoid hard runtime dep on @framers/agentos)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Subset of ExtensionDescriptor required by this pack. */
|
|
29
|
+
interface ExtensionDescriptor {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: string;
|
|
32
|
+
payload: unknown;
|
|
33
|
+
enableByDefault?: boolean;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subset of ExtensionPack required by this pack. */
|
|
38
|
+
interface ExtensionPack {
|
|
39
|
+
id: string;
|
|
40
|
+
descriptors: ExtensionDescriptor[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Subset of ExtensionPackContext required by this pack. */
|
|
44
|
+
interface ExtensionPackContext {
|
|
45
|
+
getSecret?: (id: string) => string | undefined;
|
|
46
|
+
options?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Kind constant matching packages/agentos/src/extensions/types.ts. */
|
|
50
|
+
const EXTENSION_KIND_DIARIZATION = 'diarization-provider';
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Factory
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a standalone {@link DiarizationEngine} instance.
|
|
58
|
+
*
|
|
59
|
+
* Use this when composing the engine programmatically outside of the
|
|
60
|
+
* AgentOS extension system.
|
|
61
|
+
*
|
|
62
|
+
* @returns A configured {@link DiarizationEngine}.
|
|
63
|
+
*/
|
|
64
|
+
export function createDiarizationEngine(): DiarizationEngine {
|
|
65
|
+
return new DiarizationEngine();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* AgentOS manifest factory function.
|
|
70
|
+
*
|
|
71
|
+
* Returns an {@link ExtensionPack} containing a single `diarization-provider`
|
|
72
|
+
* descriptor backed by {@link DiarizationEngine}.
|
|
73
|
+
*
|
|
74
|
+
* @param _context - Pack context supplied by the extension manager.
|
|
75
|
+
* @returns A fully configured {@link ExtensionPack}.
|
|
76
|
+
*/
|
|
77
|
+
export function createExtensionPack(_context: ExtensionPackContext): ExtensionPack {
|
|
78
|
+
const engine = new DiarizationEngine();
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id: 'diarization',
|
|
82
|
+
descriptors: [
|
|
83
|
+
{
|
|
84
|
+
id: 'diarization-engine',
|
|
85
|
+
kind: EXTENSION_KIND_DIARIZATION,
|
|
86
|
+
payload: engine,
|
|
87
|
+
enableByDefault: true,
|
|
88
|
+
metadata: { providerId: 'diarization-local' },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Re-exports
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export * from './types.js';
|
|
99
|
+
export { DiarizationEngine } from './DiarizationEngine.js';
|
|
100
|
+
export { DiarizationSession } from './DiarizationSession.js';
|
|
101
|
+
export { SpeakerEmbeddingCache, cosineSimilarity } from './SpeakerEmbeddingCache.js';
|
|
102
|
+
export { ClusteringStrategy } from './ClusteringStrategy.js';
|
|
103
|
+
export { SlidingWindowExtractor } from './SlidingWindowExtractor.js';
|
|
104
|
+
export { ProviderDiarizationBackend } from './ProviderDiarizationBackend.js';
|
|
105
|
+
export { LocalDiarizationBackend } from './LocalDiarizationBackend.js';
|