@siduri-x/voice 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.
@@ -0,0 +1,31 @@
1
+ import { VoiceOrgan, AudioEvent, ExperienceAdapter, ExperienceEvent, ExperienceAdapterResult } from '@siduri-x/core';
2
+ export interface VoicevoxConfig {
3
+ baseUrl: string;
4
+ speakerId: number;
5
+ maxQueueDepth?: number;
6
+ timeoutMs?: number;
7
+ maxTextLength?: number;
8
+ }
9
+ export declare class VoicevoxAdapter implements VoiceOrgan, ExperienceAdapter {
10
+ private config;
11
+ readonly kind: "voice";
12
+ private queue;
13
+ private sequenceCounter;
14
+ private currentJob;
15
+ private isProcessing;
16
+ private callbacks;
17
+ private readonly maxQueueDepth;
18
+ private readonly timeoutMs;
19
+ private readonly maxTextLength;
20
+ constructor(config: VoicevoxConfig);
21
+ handleEvent(event: ExperienceEvent): Promise<ExperienceAdapterResult>;
22
+ enqueueSpeech(text: string, language: string, priority?: number): string;
23
+ onLifecycleEvent(callback: (event: AudioEvent) => void): void;
24
+ getQueueStatus(): {
25
+ pending: number;
26
+ current?: string;
27
+ };
28
+ private emit;
29
+ private processQueue;
30
+ synthesize(text: string): Promise<Uint8Array>;
31
+ }
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoicevoxAdapter = void 0;
4
+ const core_1 = require("@siduri-x/core");
5
+ class VoicevoxAdapter {
6
+ config;
7
+ kind = 'voice';
8
+ queue = [];
9
+ sequenceCounter = 0;
10
+ currentJob;
11
+ isProcessing = false;
12
+ callbacks = [];
13
+ maxQueueDepth;
14
+ timeoutMs;
15
+ maxTextLength;
16
+ constructor(config) {
17
+ this.config = config;
18
+ this.maxQueueDepth = config.maxQueueDepth ?? 50;
19
+ this.timeoutMs = config.timeoutMs ?? 10_000;
20
+ this.maxTextLength = config.maxTextLength ?? 4000;
21
+ }
22
+ async handleEvent(event) {
23
+ const validation = (0, core_1.validateExperienceEvent)(event);
24
+ if (!validation.valid) {
25
+ return {
26
+ accepted: false,
27
+ eventId: event?.eventId || '',
28
+ lifecycle: 'FAILED',
29
+ error: validation.error,
30
+ reason: 'INVALID_EVENT_ENVELOPE',
31
+ };
32
+ }
33
+ if (event.kind !== 'voice') {
34
+ return {
35
+ accepted: false,
36
+ eventId: event.eventId,
37
+ lifecycle: 'FAILED',
38
+ error: `Voice adapter received incompatible event kind: ${event.kind}`,
39
+ reason: 'INCOMPATIBLE_EVENT_KIND',
40
+ };
41
+ }
42
+ if (event.approval !== 'APPROVED') {
43
+ return {
44
+ accepted: false,
45
+ eventId: event.eventId,
46
+ lifecycle: 'FAILED',
47
+ error: 'Event is not APPROVED',
48
+ reason: 'APPROVAL_REQUIRED',
49
+ };
50
+ }
51
+ if (this.queue.length >= this.maxQueueDepth) {
52
+ return {
53
+ accepted: false,
54
+ eventId: event.eventId,
55
+ lifecycle: 'FAILED',
56
+ error: `Voice queue capacity exceeded (current depth: ${this.queue.length}, max: ${this.maxQueueDepth})`,
57
+ reason: 'QUEUE_CAPACITY_EXCEEDED',
58
+ };
59
+ }
60
+ const text = (event.text ?? '').slice(0, this.maxTextLength);
61
+ const language = event.language ?? 'ja';
62
+ const speechId = this.enqueueSpeech(text, language, 1);
63
+ return {
64
+ accepted: true,
65
+ eventId: event.eventId,
66
+ lifecycle: 'STARTED',
67
+ metadata: {
68
+ speechId,
69
+ companionId: event.companionId,
70
+ correlationId: event.correlationId,
71
+ },
72
+ };
73
+ }
74
+ enqueueSpeech(text, language, priority = 0) {
75
+ if (this.queue.length >= this.maxQueueDepth) {
76
+ throw new Error(`Voice queue capacity exceeded (current depth: ${this.queue.length}, max: ${this.maxQueueDepth})`);
77
+ }
78
+ const boundedText = (text || '').slice(0, this.maxTextLength);
79
+ const id = `job_${Math.random().toString(36).substr(2, 9)}`;
80
+ this.queue.push({
81
+ id,
82
+ text: boundedText,
83
+ language,
84
+ priority,
85
+ sequence: this.sequenceCounter++
86
+ });
87
+ // Sort: highest priority first, then lowest sequence
88
+ this.queue.sort((a, b) => {
89
+ if (a.priority !== b.priority) {
90
+ return b.priority - a.priority;
91
+ }
92
+ return a.sequence - b.sequence;
93
+ });
94
+ this.processQueue();
95
+ return id;
96
+ }
97
+ onLifecycleEvent(callback) {
98
+ this.callbacks.push(callback);
99
+ }
100
+ getQueueStatus() {
101
+ return {
102
+ pending: this.queue.length,
103
+ current: this.currentJob
104
+ };
105
+ }
106
+ emit(event) {
107
+ for (const cb of this.callbacks) {
108
+ try {
109
+ cb(event);
110
+ }
111
+ catch (e) { }
112
+ }
113
+ }
114
+ async processQueue() {
115
+ if (this.isProcessing || this.queue.length === 0)
116
+ return;
117
+ this.isProcessing = true;
118
+ while (this.queue.length > 0) {
119
+ const job = this.queue.shift();
120
+ this.currentJob = job.id;
121
+ this.emit({ type: 'STARTED', speechId: job.id, text: job.text, language: job.language });
122
+ try {
123
+ const audioBuffer = await this.synthesize(job.text);
124
+ this.emit({ type: 'COMPLETED', speechId: job.id, text: job.text, language: job.language, audioBuffer });
125
+ }
126
+ catch (error) {
127
+ this.emit({ type: 'FAILED', speechId: job.id, text: job.text, language: job.language });
128
+ }
129
+ this.currentJob = undefined;
130
+ }
131
+ this.isProcessing = false;
132
+ }
133
+ async synthesize(text) {
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
136
+ try {
137
+ // 1. /audio_query
138
+ const queryUrl = new URL('/audio_query', this.config.baseUrl);
139
+ queryUrl.searchParams.set('text', text);
140
+ queryUrl.searchParams.set('speaker', this.config.speakerId.toString());
141
+ const queryResponse = await fetch(queryUrl.toString(), {
142
+ method: 'POST',
143
+ headers: { 'Accept': 'application/json' },
144
+ signal: controller.signal,
145
+ });
146
+ if (!queryResponse.ok) {
147
+ throw new Error(`Voicevox audio_query failed: ${queryResponse.statusText}`);
148
+ }
149
+ const queryJson = await queryResponse.json();
150
+ // 2. /synthesis
151
+ const synthUrl = new URL('/synthesis', this.config.baseUrl);
152
+ synthUrl.searchParams.set('speaker', this.config.speakerId.toString());
153
+ const synthResponse = await fetch(synthUrl.toString(), {
154
+ method: 'POST',
155
+ headers: {
156
+ 'Accept': 'audio/wav',
157
+ 'Content-Type': 'application/json'
158
+ },
159
+ body: JSON.stringify(queryJson),
160
+ signal: controller.signal,
161
+ });
162
+ if (!synthResponse.ok) {
163
+ throw new Error(`Voicevox synthesis failed: ${synthResponse.statusText}`);
164
+ }
165
+ const buffer = await synthResponse.arrayBuffer();
166
+ return new Uint8Array(buffer);
167
+ }
168
+ finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }
172
+ }
173
+ exports.VoicevoxAdapter = VoicevoxAdapter;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ // Mock fetch
5
+ global.fetch = jest.fn();
6
+ describe('VoicevoxAdapter Queue Semantics', () => {
7
+ let adapter;
8
+ beforeEach(() => {
9
+ adapter = new index_1.VoicevoxAdapter({ baseUrl: 'http://localhost:50021', speakerId: 1 });
10
+ global.fetch.mockClear();
11
+ });
12
+ test('queue preserves priority and sequence ordering', async () => {
13
+ // We want to stop processQueue from consuming everything instantly so we can inspect it.
14
+ // Let's mock synthesize to never resolve immediately, or just inspect the queue property.
15
+ adapter.isProcessing = true; // Block processing
16
+ adapter.enqueueSpeech("Low 1", "en", 0);
17
+ adapter.enqueueSpeech("High 1", "en", 100);
18
+ adapter.enqueueSpeech("Low 2", "en", 0);
19
+ adapter.enqueueSpeech("High 2", "en", 100);
20
+ const queue = adapter.queue;
21
+ // High priority first, preserving insertion order among same priority
22
+ expect(queue[0].text).toBe("High 1");
23
+ expect(queue[1].text).toBe("High 2");
24
+ expect(queue[2].text).toBe("Low 1");
25
+ expect(queue[3].text).toBe("Low 2");
26
+ });
27
+ test('synthesize calls /audio_query and /synthesis', async () => {
28
+ global.fetch.mockResolvedValueOnce({
29
+ ok: true,
30
+ json: async () => ({ some: "query_data" })
31
+ });
32
+ global.fetch.mockResolvedValueOnce({
33
+ ok: true,
34
+ arrayBuffer: async () => new ArrayBuffer(8)
35
+ });
36
+ const audio = await adapter.synthesize("Hello");
37
+ expect(audio.length).toBe(8);
38
+ expect(global.fetch).toHaveBeenCalledTimes(2);
39
+ const [queryCall, synthCall] = global.fetch.mock.calls;
40
+ expect(queryCall[0]).toContain("/audio_query?text=Hello&speaker=1");
41
+ expect(synthCall[0]).toContain("/synthesis?speaker=1");
42
+ expect(JSON.parse(synthCall[1].body).some).toBe("query_data");
43
+ });
44
+ });
45
+ describe('VoicevoxAdapter T5 Experience Event Interface', () => {
46
+ let adapter;
47
+ beforeEach(() => {
48
+ adapter = new index_1.VoicevoxAdapter({ baseUrl: 'http://localhost:50021', speakerId: 1 });
49
+ });
50
+ test('handleEvent processes valid approved voice event', async () => {
51
+ adapter.isProcessing = true; // prevent live http calls
52
+ const res = await adapter.handleEvent({
53
+ eventId: 'evt-voice-1',
54
+ companionId: 'companion-a',
55
+ responseId: 'resp-1',
56
+ correlationId: 'corr-1',
57
+ channel: 'public',
58
+ audienceId: 'audience-public',
59
+ approval: 'APPROVED',
60
+ kind: 'voice',
61
+ lifecycle: 'STARTED',
62
+ evidenceIds: [],
63
+ text: 'Hello T5 voice',
64
+ language: 'en',
65
+ createdAt: new Date().toISOString(),
66
+ });
67
+ expect(res.accepted).toBe(true);
68
+ expect(res.lifecycle).toBe('STARTED');
69
+ expect(res.metadata?.speechId).toBeDefined();
70
+ });
71
+ test('handleEvent rejects unapproved event or incompatible kind', async () => {
72
+ const unapprovedRes = await adapter.handleEvent({
73
+ eventId: 'evt-voice-2',
74
+ companionId: 'companion-a',
75
+ responseId: 'resp-1',
76
+ correlationId: 'corr-1',
77
+ channel: 'public',
78
+ audienceId: 'audience-public',
79
+ approval: 'STAGED',
80
+ kind: 'voice',
81
+ lifecycle: 'STARTED',
82
+ evidenceIds: [],
83
+ createdAt: new Date().toISOString(),
84
+ });
85
+ expect(unapprovedRes.accepted).toBe(false);
86
+ const incompatibleRes = await adapter.handleEvent({
87
+ eventId: 'evt-voice-3',
88
+ companionId: 'companion-a',
89
+ responseId: 'resp-1',
90
+ correlationId: 'corr-1',
91
+ channel: 'public',
92
+ audienceId: 'audience-public',
93
+ approval: 'APPROVED',
94
+ kind: 'avatar',
95
+ lifecycle: 'STARTED',
96
+ evidenceIds: [],
97
+ createdAt: new Date().toISOString(),
98
+ });
99
+ expect(incompatibleRes.accepted).toBe(false);
100
+ expect(incompatibleRes.reason).toBe('INCOMPATIBLE_EVENT_KIND');
101
+ });
102
+ test('handleEvent enforces queue depth limits and applies backpressure', async () => {
103
+ const smallQueueAdapter = new index_1.VoicevoxAdapter({
104
+ baseUrl: 'http://localhost:50021',
105
+ speakerId: 1,
106
+ maxQueueDepth: 2,
107
+ });
108
+ smallQueueAdapter.isProcessing = true; // hold queue
109
+ // Fill queue to capacity
110
+ smallQueueAdapter.enqueueSpeech('msg 1', 'en');
111
+ smallQueueAdapter.enqueueSpeech('msg 2', 'en');
112
+ // Attempting to enqueue when full via enqueueSpeech throws
113
+ expect(() => smallQueueAdapter.enqueueSpeech('msg 3', 'en')).toThrow(/Voice queue capacity exceeded/);
114
+ // Attempting via handleEvent returns structured failure with QUEUE_CAPACITY_EXCEEDED
115
+ const eventRes = await smallQueueAdapter.handleEvent({
116
+ eventId: 'evt-voice-overflow',
117
+ companionId: 'companion-a',
118
+ responseId: 'resp-1',
119
+ correlationId: 'corr-1',
120
+ channel: 'public',
121
+ audienceId: 'audience-public',
122
+ approval: 'APPROVED',
123
+ kind: 'voice',
124
+ lifecycle: 'STARTED',
125
+ evidenceIds: [],
126
+ text: 'overflow text',
127
+ createdAt: new Date().toISOString(),
128
+ });
129
+ expect(eventRes.accepted).toBe(false);
130
+ expect(eventRes.reason).toBe('QUEUE_CAPACITY_EXCEEDED');
131
+ });
132
+ });
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@siduri-x/voice",
3
+ "organType": "voice",
4
+ "version": "1.0.0",
5
+ "displayName": "Voice (VOICEVOX Speech Synthesis)",
6
+ "description": "Queued speech synthesis and audio rendering lifecycle adapter",
7
+ "entrypoint": "./dist/index.js",
8
+ "factory": "VoicevoxAdapter",
9
+ "configKey": "voice",
10
+ "configSchema": {
11
+ "type": "object",
12
+ "required": [
13
+ "provider"
14
+ ],
15
+ "properties": {
16
+ "provider": {
17
+ "type": "string",
18
+ "enum": [
19
+ "voicevox",
20
+ "none"
21
+ ]
22
+ },
23
+ "speakerId": {
24
+ "type": "number",
25
+ "default": 1
26
+ },
27
+ "baseUrl": {
28
+ "type": "string",
29
+ "default": "http://localhost:50021"
30
+ },
31
+ "maxQueueDepth": {
32
+ "type": "number",
33
+ "default": 50
34
+ }
35
+ }
36
+ },
37
+ "environment": [
38
+ {
39
+ "name": "VOICEVOX_URL",
40
+ "required": false,
41
+ "secret": false,
42
+ "default": "http://localhost:50021",
43
+ "description": "Base URL of the running VOICEVOX engine"
44
+ }
45
+ ],
46
+ "services": [
47
+ {
48
+ "name": "VOICEVOX Engine",
49
+ "kind": "http_service",
50
+ "optional": false
51
+ }
52
+ ],
53
+ "database": null,
54
+ "healthCheck": null
55
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@siduri-x/voice",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "dev": "tsc -w",
9
+ "test": "jest --config jest.config.json"
10
+ },
11
+ "dependencies": {
12
+ "@siduri-x/core": "workspace:*"
13
+ },
14
+ "devDependencies": {
15
+ "@types/jest": "^29.5.14",
16
+ "@types/node": "^26.2.0",
17
+ "jest": "^29.7.0",
18
+ "ts-jest": "^29.4.12",
19
+ "typescript": "^5.3.3"
20
+ },
21
+ "description": "Voice organ for speech synthesis, voice output queuing, and TTS provider integration",
22
+ "license": "UNLICENSED",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/vxnuslabs/siduri-y",
26
+ "directory": "packages/organs/voice"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "organ-manifest.json",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "exports": {
41
+ ".": {
42
+ "types": "./dist/index.d.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./organ-manifest.json": "./organ-manifest.json"
47
+ }
48
+ }