@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.
- package/dist/index.d.ts +31 -0
- package/dist/index.js +173 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +132 -0
- package/organ-manifest.json +55 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|