@auxiora/conversation 1.0.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 +191 -0
- package/dist/engine.d.ts +40 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +126 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +51 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +105 -0
- package/dist/stream.js.map +1 -0
- package/dist/turn-manager.d.ts +29 -0
- package/dist/turn-manager.d.ts.map +1 -0
- package/dist/turn-manager.js +69 -0
- package/dist/turn-manager.js.map +1 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/voice-personality.d.ts +24 -0
- package/dist/voice-personality.d.ts.map +1 -0
- package/dist/voice-personality.js +79 -0
- package/dist/voice-personality.js.map +1 -0
- package/package.json +27 -0
- package/src/engine.ts +156 -0
- package/src/index.ts +20 -0
- package/src/stream.ts +133 -0
- package/src/turn-manager.ts +78 -0
- package/src/types.ts +67 -0
- package/src/voice-personality.ts +90 -0
- package/tests/conversation.test.ts +310 -0
- package/tests/wiring.test.ts +13 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DEFAULT_CONVERSATION_CONFIG } from './types.js';
|
|
2
|
+
/** Filler words used during thinking pauses. */
|
|
3
|
+
const FILLER_WORDS = ['um', 'uh', 'hmm', 'let me think', 'well'];
|
|
4
|
+
/** Backchannel responses to acknowledge the user. */
|
|
5
|
+
const BACKCHANNEL_RESPONSES = ['uh-huh', 'yeah', 'I see', 'right', 'got it', 'mm-hmm'];
|
|
6
|
+
/**
|
|
7
|
+
* Manages turn-taking, natural pauses, backchanneling, and filler words.
|
|
8
|
+
*/
|
|
9
|
+
export class TurnManager {
|
|
10
|
+
config;
|
|
11
|
+
lastSpeechEnd = 0;
|
|
12
|
+
turnHistory = [];
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = { ...DEFAULT_CONVERSATION_CONFIG, ...config };
|
|
15
|
+
}
|
|
16
|
+
/** Record the end of a speech turn. */
|
|
17
|
+
recordTurnEnd() {
|
|
18
|
+
this.lastSpeechEnd = Date.now();
|
|
19
|
+
}
|
|
20
|
+
/** Check if enough silence has passed to end the current turn. */
|
|
21
|
+
isTurnComplete() {
|
|
22
|
+
if (this.lastSpeechEnd === 0)
|
|
23
|
+
return false;
|
|
24
|
+
return (Date.now() - this.lastSpeechEnd) >= this.config.silenceTimeout;
|
|
25
|
+
}
|
|
26
|
+
/** Get a random filler word for thinking pauses. */
|
|
27
|
+
getFiller() {
|
|
28
|
+
if (!this.config.fillersEnabled)
|
|
29
|
+
return null;
|
|
30
|
+
return FILLER_WORDS[Math.floor(Math.random() * FILLER_WORDS.length)];
|
|
31
|
+
}
|
|
32
|
+
/** Get a backchannel response to acknowledge user speech. */
|
|
33
|
+
getBackchannel() {
|
|
34
|
+
if (!this.config.backchannelEnabled)
|
|
35
|
+
return null;
|
|
36
|
+
return BACKCHANNEL_RESPONSES[Math.floor(Math.random() * BACKCHANNEL_RESPONSES.length)];
|
|
37
|
+
}
|
|
38
|
+
/** Detect if the user is attempting to interrupt. */
|
|
39
|
+
detectInterruption(speechDuration) {
|
|
40
|
+
if (!this.config.interruptionEnabled)
|
|
41
|
+
return false;
|
|
42
|
+
return speechDuration >= this.config.minSpeechDuration;
|
|
43
|
+
}
|
|
44
|
+
/** Calculate natural pause duration between sentences. */
|
|
45
|
+
calculatePause(sentenceLength) {
|
|
46
|
+
// Longer sentences get slightly longer pauses
|
|
47
|
+
const basePause = 200;
|
|
48
|
+
const lengthFactor = Math.min(sentenceLength / 100, 2);
|
|
49
|
+
return Math.round(basePause + lengthFactor * 150);
|
|
50
|
+
}
|
|
51
|
+
/** Add a turn event to history. */
|
|
52
|
+
addToHistory(event) {
|
|
53
|
+
this.turnHistory.push(event);
|
|
54
|
+
// Keep last 50 turns
|
|
55
|
+
if (this.turnHistory.length > 50) {
|
|
56
|
+
this.turnHistory = this.turnHistory.slice(-50);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Get recent turn history. */
|
|
60
|
+
getHistory(limit = 10) {
|
|
61
|
+
return this.turnHistory.slice(-limit);
|
|
62
|
+
}
|
|
63
|
+
/** Reset turn state. */
|
|
64
|
+
reset() {
|
|
65
|
+
this.lastSpeechEnd = 0;
|
|
66
|
+
this.turnHistory = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=turn-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-manager.js","sourceRoot":"","sources":["../src/turn-manager.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAEzD,gDAAgD;AAChD,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC;AAEjE,qDAAqD;AACrD,MAAM,qBAAqB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAEvF;;GAEG;AACH,MAAM,OAAO,WAAW;IACd,MAAM,CAAqB;IAC3B,aAAa,GAAG,CAAC,CAAC;IAClB,WAAW,GAAgB,EAAE,CAAC;IAEtC,YAAY,MAAoC;QAC9C,IAAI,CAAC,MAAM,GAAG,EAAE,GAAG,2BAA2B,EAAE,GAAG,MAAM,EAAE,CAAC;IAC9D,CAAC;IAED,uCAAuC;IACvC,aAAa;QACX,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAClC,CAAC;IAED,kEAAkE;IAClE,cAAc;QACZ,IAAI,IAAI,CAAC,aAAa,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAC3C,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC;IACzE,CAAC;IAED,oDAAoD;IACpD,SAAS;QACP,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc;YAAE,OAAO,IAAI,CAAC;QAC7C,OAAO,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,6DAA6D;IAC7D,cAAc;QACZ,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,kBAAkB;YAAE,OAAO,IAAI,CAAC;QACjD,OAAO,qBAAqB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC,CAAC;IACzF,CAAC;IAED,qDAAqD;IACrD,kBAAkB,CAAC,cAAsB;QACvC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,mBAAmB;YAAE,OAAO,KAAK,CAAC;QACnD,OAAO,cAAc,IAAI,IAAI,CAAC,MAAM,CAAC,iBAAiB,CAAC;IACzD,CAAC;IAED,0DAA0D;IAC1D,cAAc,CAAC,cAAsB;QACnC,8CAA8C;QAC9C,MAAM,SAAS,GAAG,GAAG,CAAC;QACtB,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,cAAc,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC;QACvD,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,YAAY,GAAG,GAAG,CAAC,CAAC;IACpD,CAAC;IAED,mCAAmC;IACnC,YAAY,CAAC,KAAgB;QAC3B,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7B,qBAAqB;QACrB,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;YACjC,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;QACjD,CAAC;IACH,CAAC;IAED,+BAA+B;IAC/B,UAAU,CAAC,KAAK,GAAG,EAAE;QACnB,OAAO,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;IACxC,CAAC;IAED,wBAAwB;IACxB,KAAK;QACH,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,WAAW,GAAG,EAAE,CAAC;IACxB,CAAC;CACF"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** States of a real-time conversation. */
|
|
2
|
+
export type ConversationState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'interrupted';
|
|
3
|
+
/** Events emitted during a conversation turn. */
|
|
4
|
+
export interface TurnEvent {
|
|
5
|
+
type: 'user_speech' | 'ai_response' | 'interruption' | 'silence' | 'backchannel' | 'filler';
|
|
6
|
+
timestamp: number;
|
|
7
|
+
/** Transcribed text (for user_speech) or response text (for ai_response). */
|
|
8
|
+
text?: string;
|
|
9
|
+
/** Audio data associated with this event. */
|
|
10
|
+
audio?: Buffer;
|
|
11
|
+
/** Duration in milliseconds. */
|
|
12
|
+
duration?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Voice personality settings for natural conversation. */
|
|
15
|
+
export interface VoicePersonality {
|
|
16
|
+
/** Speaking pace (0.5 = slow, 1.0 = normal, 1.5 = fast). */
|
|
17
|
+
pace: number;
|
|
18
|
+
/** Pitch adjustment (-1.0 to 1.0). */
|
|
19
|
+
pitch: number;
|
|
20
|
+
/** How often to use filler words (0 = never, 1 = frequently). */
|
|
21
|
+
fillerStyle: number;
|
|
22
|
+
/** Natural pause duration in milliseconds between sentences. */
|
|
23
|
+
pauseDuration: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_VOICE_PERSONALITY: VoicePersonality;
|
|
26
|
+
/** Configuration for the conversation engine. */
|
|
27
|
+
export interface ConversationConfig {
|
|
28
|
+
/** Voice activity detection sensitivity (0-1). */
|
|
29
|
+
vadSensitivity: number;
|
|
30
|
+
/** Maximum silence before ending a turn (ms). */
|
|
31
|
+
silenceTimeout: number;
|
|
32
|
+
/** Minimum speech duration to register as a turn (ms). */
|
|
33
|
+
minSpeechDuration: number;
|
|
34
|
+
/** Whether to enable interruption detection. */
|
|
35
|
+
interruptionEnabled: boolean;
|
|
36
|
+
/** Whether to insert filler words while thinking. */
|
|
37
|
+
fillersEnabled: boolean;
|
|
38
|
+
/** Whether to enable backchannel responses (uh-huh, yeah, etc.). */
|
|
39
|
+
backchannelEnabled: boolean;
|
|
40
|
+
/** Echo cancellation hint for the audio system. */
|
|
41
|
+
echoCancellation: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_CONVERSATION_CONFIG: ConversationConfig;
|
|
44
|
+
/** Callback for conversation turn events. */
|
|
45
|
+
export type TurnHandler = (event: TurnEvent) => void | Promise<void>;
|
|
46
|
+
/** Callback for state changes. */
|
|
47
|
+
export type StateHandler = (from: ConversationState, to: ConversationState) => void;
|
|
48
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,GAAG,aAAa,CAAC;AAE/F,iDAAiD;AACjD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,aAAa,GAAG,aAAa,GAAG,cAAc,GAAG,SAAS,GAAG,aAAa,GAAG,QAAQ,CAAC;IAC5F,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,WAAW,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,eAAO,MAAM,yBAAyB,EAAE,gBAKvC,CAAC;AAEF,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,cAAc,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,cAAc,EAAE,MAAM,CAAC;IACvB,0DAA0D;IAC1D,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,mBAAmB,EAAE,OAAO,CAAC;IAC7B,qDAAqD;IACrD,cAAc,EAAE,OAAO,CAAC;IACxB,oEAAoE;IACpE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mDAAmD;IACnD,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED,eAAO,MAAM,2BAA2B,EAAE,kBAQzC,CAAC;AAEF,6CAA6C;AAC7C,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAErE,kCAAkC;AAClC,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,iBAAiB,EAAE,EAAE,EAAE,iBAAiB,KAAK,IAAI,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const DEFAULT_VOICE_PERSONALITY = {
|
|
2
|
+
pace: 1.0,
|
|
3
|
+
pitch: 0.0,
|
|
4
|
+
fillerStyle: 0.2,
|
|
5
|
+
pauseDuration: 300,
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_CONVERSATION_CONFIG = {
|
|
8
|
+
vadSensitivity: 0.5,
|
|
9
|
+
silenceTimeout: 1500,
|
|
10
|
+
minSpeechDuration: 300,
|
|
11
|
+
interruptionEnabled: true,
|
|
12
|
+
fillersEnabled: true,
|
|
13
|
+
backchannelEnabled: true,
|
|
14
|
+
echoCancellation: true,
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA2BA,MAAM,CAAC,MAAM,yBAAyB,GAAqB;IACzD,IAAI,EAAE,GAAG;IACT,KAAK,EAAE,GAAG;IACV,WAAW,EAAE,GAAG;IAChB,aAAa,EAAE,GAAG;CACnB,CAAC;AAoBF,MAAM,CAAC,MAAM,2BAA2B,GAAuB;IAC7D,cAAc,EAAE,GAAG;IACnB,cAAc,EAAE,IAAI;IACpB,iBAAiB,EAAE,GAAG;IACtB,mBAAmB,EAAE,IAAI;IACzB,cAAc,EAAE,IAAI;IACpB,kBAAkB,EAAE,IAAI;IACxB,gBAAgB,EAAE,IAAI;CACvB,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { VoicePersonality } from './types.js';
|
|
2
|
+
import type { TTSOptions } from '@auxiora/tts';
|
|
3
|
+
/**
|
|
4
|
+
* Maps personality templates to TTS voice parameters.
|
|
5
|
+
*/
|
|
6
|
+
export declare class VoicePersonalityAdapter {
|
|
7
|
+
private personality;
|
|
8
|
+
constructor(personality?: Partial<VoicePersonality>);
|
|
9
|
+
/** Load a named personality template. */
|
|
10
|
+
static fromTemplate(name: string): VoicePersonalityAdapter;
|
|
11
|
+
/** List available personality template names. */
|
|
12
|
+
static listTemplates(): string[];
|
|
13
|
+
/** Get the current voice personality settings. */
|
|
14
|
+
getPersonality(): VoicePersonality;
|
|
15
|
+
/** Convert personality to TTS options. */
|
|
16
|
+
toTTSOptions(baseOptions?: TTSOptions): TTSOptions;
|
|
17
|
+
/** Get the natural pause duration for this personality. */
|
|
18
|
+
getPauseDuration(): number;
|
|
19
|
+
/** Whether this personality uses filler words. */
|
|
20
|
+
useFillers(): boolean;
|
|
21
|
+
/** Get filler word probability (0-1). */
|
|
22
|
+
getFillerProbability(): number;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=voice-personality.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-personality.d.ts","sourceRoot":"","sources":["../src/voice-personality.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAoC/C;;GAEG;AACH,qBAAa,uBAAuB;IAClC,OAAO,CAAC,WAAW,CAAmB;gBAE1B,WAAW,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAInD,yCAAyC;IACzC,MAAM,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,uBAAuB;IAQ1D,iDAAiD;IACjD,MAAM,CAAC,aAAa,IAAI,MAAM,EAAE;IAIhC,kDAAkD;IAClD,cAAc,IAAI,gBAAgB;IAIlC,0CAA0C;IAC1C,YAAY,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,UAAU;IAOlD,2DAA2D;IAC3D,gBAAgB,IAAI,MAAM;IAI1B,kDAAkD;IAClD,UAAU,IAAI,OAAO;IAIrB,yCAAyC;IACzC,oBAAoB,IAAI,MAAM;CAG/B"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DEFAULT_VOICE_PERSONALITY } from './types.js';
|
|
2
|
+
/** Named personality templates mapped to voice parameters. */
|
|
3
|
+
const PERSONALITY_TEMPLATES = {
|
|
4
|
+
friendly: {
|
|
5
|
+
pace: 1.0,
|
|
6
|
+
pitch: 0.1,
|
|
7
|
+
fillerStyle: 0.3,
|
|
8
|
+
pauseDuration: 250,
|
|
9
|
+
},
|
|
10
|
+
professional: {
|
|
11
|
+
pace: 0.95,
|
|
12
|
+
pitch: 0.0,
|
|
13
|
+
fillerStyle: 0.05,
|
|
14
|
+
pauseDuration: 350,
|
|
15
|
+
},
|
|
16
|
+
enthusiastic: {
|
|
17
|
+
pace: 1.15,
|
|
18
|
+
pitch: 0.2,
|
|
19
|
+
fillerStyle: 0.4,
|
|
20
|
+
pauseDuration: 200,
|
|
21
|
+
},
|
|
22
|
+
calm: {
|
|
23
|
+
pace: 0.85,
|
|
24
|
+
pitch: -0.1,
|
|
25
|
+
fillerStyle: 0.1,
|
|
26
|
+
pauseDuration: 450,
|
|
27
|
+
},
|
|
28
|
+
concise: {
|
|
29
|
+
pace: 1.1,
|
|
30
|
+
pitch: 0.0,
|
|
31
|
+
fillerStyle: 0.0,
|
|
32
|
+
pauseDuration: 200,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Maps personality templates to TTS voice parameters.
|
|
37
|
+
*/
|
|
38
|
+
export class VoicePersonalityAdapter {
|
|
39
|
+
personality;
|
|
40
|
+
constructor(personality) {
|
|
41
|
+
this.personality = { ...DEFAULT_VOICE_PERSONALITY, ...personality };
|
|
42
|
+
}
|
|
43
|
+
/** Load a named personality template. */
|
|
44
|
+
static fromTemplate(name) {
|
|
45
|
+
const template = PERSONALITY_TEMPLATES[name];
|
|
46
|
+
if (!template) {
|
|
47
|
+
return new VoicePersonalityAdapter();
|
|
48
|
+
}
|
|
49
|
+
return new VoicePersonalityAdapter(template);
|
|
50
|
+
}
|
|
51
|
+
/** List available personality template names. */
|
|
52
|
+
static listTemplates() {
|
|
53
|
+
return Object.keys(PERSONALITY_TEMPLATES);
|
|
54
|
+
}
|
|
55
|
+
/** Get the current voice personality settings. */
|
|
56
|
+
getPersonality() {
|
|
57
|
+
return { ...this.personality };
|
|
58
|
+
}
|
|
59
|
+
/** Convert personality to TTS options. */
|
|
60
|
+
toTTSOptions(baseOptions) {
|
|
61
|
+
return {
|
|
62
|
+
...baseOptions,
|
|
63
|
+
speed: this.personality.pace,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Get the natural pause duration for this personality. */
|
|
67
|
+
getPauseDuration() {
|
|
68
|
+
return this.personality.pauseDuration;
|
|
69
|
+
}
|
|
70
|
+
/** Whether this personality uses filler words. */
|
|
71
|
+
useFillers() {
|
|
72
|
+
return this.personality.fillerStyle > 0;
|
|
73
|
+
}
|
|
74
|
+
/** Get filler word probability (0-1). */
|
|
75
|
+
getFillerProbability() {
|
|
76
|
+
return this.personality.fillerStyle;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=voice-personality.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-personality.js","sourceRoot":"","sources":["../src/voice-personality.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAGvD,8DAA8D;AAC9D,MAAM,qBAAqB,GAAqC;IAC9D,QAAQ,EAAE;QACR,IAAI,EAAE,GAAG;QACT,KAAK,EAAE,GAAG;QACV,WAAW,EAAE,GAAG;QAChB,aAAa,EAAE,GAAG;KACnB;IACD,YAAY,EAAE;QACZ,IAAI,EAAE,IAAI;QACV,KAAK,EAAE,GAAG;QACV,WAAW,EAAE,IAAI;QACjB,aAAa,EAAE,GAAG;KACnB;IACD,YAAY,EAAE;QACZ,IAAI,EAAE,IAAI;QACV,KAAK,EAAE,GAAG;QACV,WAAW,EAAE,GAAG;QAChB,aAAa,EAAE,GAAG;KACnB;IACD,IAAI,EAAE;QACJ,IAAI,EAAE,IAAI;QACV,KAAK,EAAE,CAAC,GAAG;QACX,WAAW,EAAE,GAAG;QAChB,aAAa,EAAE,GAAG;KACnB;IACD,OAAO,EAAE;QACP,IAAI,EAAE,GAAG;QACT,KAAK,EAAE,GAAG;QACV,WAAW,EAAE,GAAG;QAChB,aAAa,EAAE,GAAG;KACnB;CACF,CAAC;AAEF;;GAEG;AACH,MAAM,OAAO,uBAAuB;IAC1B,WAAW,CAAmB;IAEtC,YAAY,WAAuC;QACjD,IAAI,CAAC,WAAW,GAAG,EAAE,GAAG,yBAAyB,EAAE,GAAG,WAAW,EAAE,CAAC;IACtE,CAAC;IAED,yCAAyC;IACzC,MAAM,CAAC,YAAY,CAAC,IAAY;QAC9B,MAAM,QAAQ,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,IAAI,uBAAuB,EAAE,CAAC;QACvC,CAAC;QACD,OAAO,IAAI,uBAAuB,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,iDAAiD;IACjD,MAAM,CAAC,aAAa;QAClB,OAAO,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IAC5C,CAAC;IAED,kDAAkD;IAClD,cAAc;QACZ,OAAO,EAAE,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACjC,CAAC;IAED,0CAA0C;IAC1C,YAAY,CAAC,WAAwB;QACnC,OAAO;YACL,GAAG,WAAW;YACd,KAAK,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI;SAC7B,CAAC;IACJ,CAAC;IAED,2DAA2D;IAC3D,gBAAgB;QACd,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,CAAC;IACxC,CAAC;IAED,kDAAkD;IAClD,UAAU;QACR,OAAO,IAAI,CAAC,WAAW,CAAC,WAAW,GAAG,CAAC,CAAC;IAC1C,CAAC;IAED,yCAAyC;IACzC,oBAAoB;QAClB,OAAO,IAAI,CAAC,WAAW,CAAC,WAAW,CAAC;IACtC,CAAC;CACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@auxiora/conversation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real-time voice conversation engine with state machine, turn-taking, and voice personality",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@auxiora/personality": "1.0.0",
|
|
16
|
+
"@auxiora/stt": "1.0.0",
|
|
17
|
+
"@auxiora/tts": "1.0.0"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"clean": "rm -rf dist",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConversationState,
|
|
3
|
+
ConversationConfig,
|
|
4
|
+
TurnEvent,
|
|
5
|
+
TurnHandler,
|
|
6
|
+
StateHandler,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
import { DEFAULT_CONVERSATION_CONFIG } from './types.js';
|
|
9
|
+
|
|
10
|
+
/** Valid state transitions. */
|
|
11
|
+
const VALID_TRANSITIONS: Record<ConversationState, ConversationState[]> = {
|
|
12
|
+
idle: ['listening'],
|
|
13
|
+
listening: ['thinking', 'idle'],
|
|
14
|
+
thinking: ['speaking', 'idle'],
|
|
15
|
+
speaking: ['idle', 'interrupted'],
|
|
16
|
+
interrupted: ['listening', 'idle'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Conversation engine — manages the state machine for real-time voice conversations.
|
|
21
|
+
* State flow: idle -> listening -> thinking -> speaking -> idle
|
|
22
|
+
* Interruption: speaking -> interrupted -> listening
|
|
23
|
+
*/
|
|
24
|
+
export class ConversationEngine {
|
|
25
|
+
private state: ConversationState = 'idle';
|
|
26
|
+
private config: ConversationConfig;
|
|
27
|
+
private turnHandlers: TurnHandler[] = [];
|
|
28
|
+
private stateHandlers: StateHandler[] = [];
|
|
29
|
+
private turnCount = 0;
|
|
30
|
+
|
|
31
|
+
constructor(config?: Partial<ConversationConfig>) {
|
|
32
|
+
this.config = { ...DEFAULT_CONVERSATION_CONFIG, ...config };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Start a conversation session (idle -> listening). */
|
|
36
|
+
start(): void {
|
|
37
|
+
this.transition('listening');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stop the conversation (any state -> idle). */
|
|
41
|
+
stop(): void {
|
|
42
|
+
this.state = 'idle';
|
|
43
|
+
this.turnCount = 0;
|
|
44
|
+
for (const handler of this.stateHandlers) {
|
|
45
|
+
handler(this.state, 'idle');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get current state. */
|
|
50
|
+
getState(): ConversationState {
|
|
51
|
+
return this.state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Get number of turns completed. */
|
|
55
|
+
getTurnCount(): number {
|
|
56
|
+
return this.turnCount;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Register a turn event handler. */
|
|
60
|
+
onTurn(handler: TurnHandler): void {
|
|
61
|
+
this.turnHandlers.push(handler);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Register a state change handler. */
|
|
65
|
+
onStateChange(handler: StateHandler): void {
|
|
66
|
+
this.stateHandlers.push(handler);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Transition to a new state. */
|
|
70
|
+
transition(to: ConversationState): void {
|
|
71
|
+
const from = this.state;
|
|
72
|
+
const allowed = VALID_TRANSITIONS[from];
|
|
73
|
+
if (!allowed.includes(to)) {
|
|
74
|
+
throw new Error(`Invalid transition: ${from} -> ${to}`);
|
|
75
|
+
}
|
|
76
|
+
this.state = to;
|
|
77
|
+
for (const handler of this.stateHandlers) {
|
|
78
|
+
handler(from, to);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Process user speech input. */
|
|
83
|
+
async handleUserSpeech(text: string, audio?: Buffer): Promise<void> {
|
|
84
|
+
if (this.state !== 'listening') {
|
|
85
|
+
throw new Error(`Cannot process speech in state: ${this.state}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const event: TurnEvent = {
|
|
89
|
+
type: 'user_speech',
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
text,
|
|
92
|
+
audio,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await this.emitTurn(event);
|
|
96
|
+
this.transition('thinking');
|
|
97
|
+
this.turnCount++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Process AI response. */
|
|
101
|
+
async handleAIResponse(text: string, audio?: Buffer): Promise<void> {
|
|
102
|
+
if (this.state !== 'thinking') {
|
|
103
|
+
throw new Error(`Cannot send response in state: ${this.state}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.transition('speaking');
|
|
107
|
+
|
|
108
|
+
const event: TurnEvent = {
|
|
109
|
+
type: 'ai_response',
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
text,
|
|
112
|
+
audio,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await this.emitTurn(event);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Signal that speaking is complete, return to listening. */
|
|
119
|
+
finishSpeaking(): void {
|
|
120
|
+
if (this.state !== 'speaking') {
|
|
121
|
+
throw new Error(`Cannot finish speaking in state: ${this.state}`);
|
|
122
|
+
}
|
|
123
|
+
this.transition('idle');
|
|
124
|
+
this.transition('listening');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Handle an interruption (user starts talking while AI is speaking). */
|
|
128
|
+
async handleInterruption(): Promise<void> {
|
|
129
|
+
if (this.state !== 'speaking') {
|
|
130
|
+
throw new Error(`Cannot interrupt in state: ${this.state}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!this.config.interruptionEnabled) return;
|
|
134
|
+
|
|
135
|
+
this.transition('interrupted');
|
|
136
|
+
|
|
137
|
+
const event: TurnEvent = {
|
|
138
|
+
type: 'interruption',
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
await this.emitTurn(event);
|
|
142
|
+
|
|
143
|
+
this.transition('listening');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Get current config. */
|
|
147
|
+
getConfig(): ConversationConfig {
|
|
148
|
+
return { ...this.config };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async emitTurn(event: TurnEvent): Promise<void> {
|
|
152
|
+
for (const handler of this.turnHandlers) {
|
|
153
|
+
await handler(event);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ConversationState,
|
|
3
|
+
ConversationConfig,
|
|
4
|
+
TurnEvent,
|
|
5
|
+
VoicePersonality,
|
|
6
|
+
TurnHandler,
|
|
7
|
+
StateHandler,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_CONVERSATION_CONFIG,
|
|
11
|
+
DEFAULT_VOICE_PERSONALITY,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
export { ConversationEngine } from './engine.js';
|
|
14
|
+
export { TurnManager } from './turn-manager.js';
|
|
15
|
+
export { VoicePersonalityAdapter } from './voice-personality.js';
|
|
16
|
+
export {
|
|
17
|
+
AudioStreamManager,
|
|
18
|
+
type AudioStreamEvent,
|
|
19
|
+
type StreamDirection,
|
|
20
|
+
} from './stream.js';
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ConversationConfig } from './types.js';
|
|
2
|
+
import { DEFAULT_CONVERSATION_CONFIG } from './types.js';
|
|
3
|
+
|
|
4
|
+
/** Audio stream direction. */
|
|
5
|
+
export type StreamDirection = 'inbound' | 'outbound';
|
|
6
|
+
|
|
7
|
+
/** Audio stream event. */
|
|
8
|
+
export interface AudioStreamEvent {
|
|
9
|
+
direction: StreamDirection;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
/** Audio data chunk. */
|
|
12
|
+
data: Buffer;
|
|
13
|
+
/** Whether voice activity was detected in this chunk. */
|
|
14
|
+
voiceDetected?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages bidirectional audio streaming with VAD and echo cancellation hints.
|
|
19
|
+
*/
|
|
20
|
+
export class AudioStreamManager {
|
|
21
|
+
private config: ConversationConfig;
|
|
22
|
+
private inboundBuffer: Buffer[] = [];
|
|
23
|
+
private outboundBuffer: Buffer[] = [];
|
|
24
|
+
private active = false;
|
|
25
|
+
private vadState = false;
|
|
26
|
+
private listeners: Array<(event: AudioStreamEvent) => void> = [];
|
|
27
|
+
|
|
28
|
+
constructor(config?: Partial<ConversationConfig>) {
|
|
29
|
+
this.config = { ...DEFAULT_CONVERSATION_CONFIG, ...config };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Start the audio stream. */
|
|
33
|
+
start(): void {
|
|
34
|
+
this.active = true;
|
|
35
|
+
this.inboundBuffer = [];
|
|
36
|
+
this.outboundBuffer = [];
|
|
37
|
+
this.vadState = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stop the audio stream. */
|
|
41
|
+
stop(): void {
|
|
42
|
+
this.active = false;
|
|
43
|
+
this.inboundBuffer = [];
|
|
44
|
+
this.outboundBuffer = [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the stream is active. */
|
|
48
|
+
isActive(): boolean {
|
|
49
|
+
return this.active;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Push inbound audio (from microphone). */
|
|
53
|
+
pushInbound(data: Buffer): void {
|
|
54
|
+
if (!this.active) return;
|
|
55
|
+
this.inboundBuffer.push(data);
|
|
56
|
+
|
|
57
|
+
const voiceDetected = this.detectVoiceActivity(data);
|
|
58
|
+
this.vadState = voiceDetected;
|
|
59
|
+
|
|
60
|
+
this.emit({
|
|
61
|
+
direction: 'inbound',
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
data,
|
|
64
|
+
voiceDetected,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Push outbound audio (to speaker). */
|
|
69
|
+
pushOutbound(data: Buffer): void {
|
|
70
|
+
if (!this.active) return;
|
|
71
|
+
this.outboundBuffer.push(data);
|
|
72
|
+
|
|
73
|
+
this.emit({
|
|
74
|
+
direction: 'outbound',
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
data,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Get accumulated inbound audio and clear the buffer. */
|
|
81
|
+
flushInbound(): Buffer {
|
|
82
|
+
const combined = Buffer.concat(this.inboundBuffer);
|
|
83
|
+
this.inboundBuffer = [];
|
|
84
|
+
return combined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Get accumulated outbound audio and clear the buffer. */
|
|
88
|
+
flushOutbound(): Buffer {
|
|
89
|
+
const combined = Buffer.concat(this.outboundBuffer);
|
|
90
|
+
this.outboundBuffer = [];
|
|
91
|
+
return combined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether voice activity is currently detected. */
|
|
95
|
+
isVoiceActive(): boolean {
|
|
96
|
+
return this.vadState;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether echo cancellation is enabled. */
|
|
100
|
+
isEchoCancellationEnabled(): boolean {
|
|
101
|
+
return this.config.echoCancellation;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register an audio stream event listener. */
|
|
105
|
+
onAudio(listener: (event: AudioStreamEvent) => void): void {
|
|
106
|
+
this.listeners.push(listener);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Simple energy-based voice activity detection.
|
|
111
|
+
* Compares average sample amplitude against the sensitivity threshold.
|
|
112
|
+
*/
|
|
113
|
+
private detectVoiceActivity(data: Buffer): boolean {
|
|
114
|
+
if (data.length < 2) return false;
|
|
115
|
+
|
|
116
|
+
let energy = 0;
|
|
117
|
+
const samples = data.length / 2; // 16-bit samples
|
|
118
|
+
for (let i = 0; i < data.length - 1; i += 2) {
|
|
119
|
+
const sample = data.readInt16LE(i);
|
|
120
|
+
energy += Math.abs(sample);
|
|
121
|
+
}
|
|
122
|
+
const avgEnergy = energy / samples;
|
|
123
|
+
// Normalize to 0-1 range (Int16 max = 32767)
|
|
124
|
+
const normalized = avgEnergy / 32767;
|
|
125
|
+
return normalized > this.config.vadSensitivity;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private emit(event: AudioStreamEvent): void {
|
|
129
|
+
for (const listener of this.listeners) {
|
|
130
|
+
listener(event);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ConversationConfig, TurnEvent } from './types.js';
|
|
2
|
+
import { DEFAULT_CONVERSATION_CONFIG } from './types.js';
|
|
3
|
+
|
|
4
|
+
/** Filler words used during thinking pauses. */
|
|
5
|
+
const FILLER_WORDS = ['um', 'uh', 'hmm', 'let me think', 'well'];
|
|
6
|
+
|
|
7
|
+
/** Backchannel responses to acknowledge the user. */
|
|
8
|
+
const BACKCHANNEL_RESPONSES = ['uh-huh', 'yeah', 'I see', 'right', 'got it', 'mm-hmm'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manages turn-taking, natural pauses, backchanneling, and filler words.
|
|
12
|
+
*/
|
|
13
|
+
export class TurnManager {
|
|
14
|
+
private config: ConversationConfig;
|
|
15
|
+
private lastSpeechEnd = 0;
|
|
16
|
+
private turnHistory: TurnEvent[] = [];
|
|
17
|
+
|
|
18
|
+
constructor(config?: Partial<ConversationConfig>) {
|
|
19
|
+
this.config = { ...DEFAULT_CONVERSATION_CONFIG, ...config };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Record the end of a speech turn. */
|
|
23
|
+
recordTurnEnd(): void {
|
|
24
|
+
this.lastSpeechEnd = Date.now();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check if enough silence has passed to end the current turn. */
|
|
28
|
+
isTurnComplete(): boolean {
|
|
29
|
+
if (this.lastSpeechEnd === 0) return false;
|
|
30
|
+
return (Date.now() - this.lastSpeechEnd) >= this.config.silenceTimeout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Get a random filler word for thinking pauses. */
|
|
34
|
+
getFiller(): string | null {
|
|
35
|
+
if (!this.config.fillersEnabled) return null;
|
|
36
|
+
return FILLER_WORDS[Math.floor(Math.random() * FILLER_WORDS.length)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get a backchannel response to acknowledge user speech. */
|
|
40
|
+
getBackchannel(): string | null {
|
|
41
|
+
if (!this.config.backchannelEnabled) return null;
|
|
42
|
+
return BACKCHANNEL_RESPONSES[Math.floor(Math.random() * BACKCHANNEL_RESPONSES.length)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Detect if the user is attempting to interrupt. */
|
|
46
|
+
detectInterruption(speechDuration: number): boolean {
|
|
47
|
+
if (!this.config.interruptionEnabled) return false;
|
|
48
|
+
return speechDuration >= this.config.minSpeechDuration;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Calculate natural pause duration between sentences. */
|
|
52
|
+
calculatePause(sentenceLength: number): number {
|
|
53
|
+
// Longer sentences get slightly longer pauses
|
|
54
|
+
const basePause = 200;
|
|
55
|
+
const lengthFactor = Math.min(sentenceLength / 100, 2);
|
|
56
|
+
return Math.round(basePause + lengthFactor * 150);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Add a turn event to history. */
|
|
60
|
+
addToHistory(event: TurnEvent): void {
|
|
61
|
+
this.turnHistory.push(event);
|
|
62
|
+
// Keep last 50 turns
|
|
63
|
+
if (this.turnHistory.length > 50) {
|
|
64
|
+
this.turnHistory = this.turnHistory.slice(-50);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Get recent turn history. */
|
|
69
|
+
getHistory(limit = 10): TurnEvent[] {
|
|
70
|
+
return this.turnHistory.slice(-limit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reset turn state. */
|
|
74
|
+
reset(): void {
|
|
75
|
+
this.lastSpeechEnd = 0;
|
|
76
|
+
this.turnHistory = [];
|
|
77
|
+
}
|
|
78
|
+
}
|