xiaoyuan-assistant 0.5.45
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/CHANGELOG.md +388 -0
- package/README.md +229 -0
- package/XIAOYUAN-BUSINESS-FLOW.md +1214 -0
- package/example/App.vue +43 -0
- package/examples/main-siliconflow.js +24 -0
- package/examples/server/package.json +7 -0
- package/examples/server/siliconflow.cjs +117 -0
- package/package.json +18 -0
- package/scripts/cache-received-tts.mjs +69 -0
- package/src/assets/received-command.local.js +2 -0
- package/src/assets/received-command.mp3 +0 -0
- package/src/assets/received-command.zh-CN-XiaoxiaoNeural.mp3 +0 -0
- package/src/assets/received-command.zh-CN-XiaoyiNeural.mp3 +0 -0
- package/src/components/XiaoyuanAssistant.vue +519 -0
- package/src/core/builtins.js +188 -0
- package/src/core/dom.js +156 -0
- package/src/core/localCommandParser.js +434 -0
- package/src/core/manager.js +505 -0
- package/src/core/registry.js +49 -0
- package/src/index.js +100 -0
- package/src/prompts.js +24 -0
- package/src/providers/siliconflow.js +296 -0
- package/src/styles/index.css +166 -0
- package/src/vite-plugin.js +66 -0
- package/src/voice/freetts.js +238 -0
- package/src/voice/hewoyi.js +774 -0
- package/src/voice/localReceivedTTS.js +106 -0
- package/src/voice/speech.js +329 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const DB_NAME = 'xiaoyuan-tts-cache'
|
|
2
|
+
const DB_VERSION = 1
|
|
3
|
+
const STORE_NAME = 'audio'
|
|
4
|
+
const CACHE_PREFIX = 'received-command::'
|
|
5
|
+
|
|
6
|
+
const BUILTIN_RECEIVED_COMMAND_AUDIO = Object.freeze({
|
|
7
|
+
'zh-CN-XiaoyiNeural': new URL('../assets/received-command.zh-CN-XiaoyiNeural.mp3', import.meta.url).href,
|
|
8
|
+
'zh-CN-XiaoxiaoNeural': new URL('../assets/received-command.zh-CN-XiaoxiaoNeural.mp3', import.meta.url).href
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function isBrowser() {
|
|
12
|
+
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openDB() {
|
|
16
|
+
if (!isBrowser()) return Promise.reject(new Error('IndexedDB 不可用'))
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
19
|
+
request.onupgradeneeded = () => {
|
|
20
|
+
const db = request.result
|
|
21
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
22
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
request.onsuccess = () => resolve(request.result)
|
|
26
|
+
request.onerror = () => reject(request.error || new Error('打开 TTS 本地缓存失败'))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeVoice(voice = '') {
|
|
31
|
+
return String(voice || '').trim() || 'default'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeKey(voice = '') {
|
|
35
|
+
return `${CACHE_PREFIX}${normalizeVoice(voice)}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getReceivedCommandAudio(voice) {
|
|
39
|
+
if (!isBrowser()) return null
|
|
40
|
+
|
|
41
|
+
const normalized = normalizeVoice(voice)
|
|
42
|
+
const builtinUrl = BUILTIN_RECEIVED_COMMAND_AUDIO[normalized]
|
|
43
|
+
if (builtinUrl) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(builtinUrl, { cache: 'force-cache' })
|
|
46
|
+
if (response.ok) {
|
|
47
|
+
const blob = await response.blob()
|
|
48
|
+
if (blob.size) return blob
|
|
49
|
+
}
|
|
50
|
+
} catch (_) {
|
|
51
|
+
// 内置资源读取失败时继续走 IndexedDB / 首次网络 TTS 逻辑。
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const db = await openDB()
|
|
56
|
+
return await new Promise((resolve, reject) => {
|
|
57
|
+
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
58
|
+
const request = tx.objectStore(STORE_NAME).get(makeKey(voice))
|
|
59
|
+
request.onsuccess = () => resolve(request.result?.blob || null)
|
|
60
|
+
request.onerror = () => reject(request.error || new Error('读取 TTS 本地缓存失败'))
|
|
61
|
+
tx.oncomplete = () => db.close()
|
|
62
|
+
tx.onerror = () => {
|
|
63
|
+
try { db.close() } catch (_) {}
|
|
64
|
+
reject(tx.error || new Error('读取 TTS 本地缓存失败'))
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function saveReceivedCommandAudio(voice, blob) {
|
|
70
|
+
if (!isBrowser() || !(blob instanceof Blob) || !blob.size) return false
|
|
71
|
+
const db = await openDB()
|
|
72
|
+
return await new Promise((resolve, reject) => {
|
|
73
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
74
|
+
tx.objectStore(STORE_NAME).put({
|
|
75
|
+
key: makeKey(voice),
|
|
76
|
+
voice: normalizeVoice(voice),
|
|
77
|
+
blob,
|
|
78
|
+
updatedAt: Date.now()
|
|
79
|
+
})
|
|
80
|
+
tx.oncomplete = () => {
|
|
81
|
+
db.close()
|
|
82
|
+
resolve(true)
|
|
83
|
+
}
|
|
84
|
+
tx.onerror = () => {
|
|
85
|
+
try { db.close() } catch (_) {}
|
|
86
|
+
reject(tx.error || new Error('保存 TTS 本地缓存失败'))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function removeReceivedCommandAudio(voice) {
|
|
92
|
+
if (!isBrowser()) return false
|
|
93
|
+
const db = await openDB()
|
|
94
|
+
return await new Promise((resolve, reject) => {
|
|
95
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
96
|
+
tx.objectStore(STORE_NAME).delete(makeKey(voice))
|
|
97
|
+
tx.oncomplete = () => {
|
|
98
|
+
db.close()
|
|
99
|
+
resolve(true)
|
|
100
|
+
}
|
|
101
|
+
tx.onerror = () => {
|
|
102
|
+
try { db.close() } catch (_) {}
|
|
103
|
+
reject(tx.error || new Error('删除 TTS 本地缓存失败'))
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
export class SpeechService {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.lang = options.lang || 'zh-CN'
|
|
4
|
+
this.voice = options.voice || null
|
|
5
|
+
this.rate = options.rate ?? 1
|
|
6
|
+
this.pitch = options.pitch ?? 1
|
|
7
|
+
this.volume = options.volume ?? 1
|
|
8
|
+
this.recognition = null
|
|
9
|
+
this.listening = false
|
|
10
|
+
this.onInterim = options.onInterim || (() => {})
|
|
11
|
+
this.onFinal = options.onFinal || (() => {})
|
|
12
|
+
this.onError = options.onError || (() => {})
|
|
13
|
+
this.onEnd = options.onEnd || (() => {})
|
|
14
|
+
this.ttsProvider = options.ttsProvider || null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async unlockTTS() {
|
|
18
|
+
return await this.ttsProvider?.unlock?.()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
supported() {
|
|
22
|
+
return typeof window !== 'undefined' && !!(
|
|
23
|
+
window.SpeechRecognition ||
|
|
24
|
+
window.webkitSpeechRecognition
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async requestMicrophonePermission() {
|
|
29
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
34
|
+
for (const track of stream.getTracks()) track.stop()
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
createRecognition({ continuous = false } = {}) {
|
|
39
|
+
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
40
|
+
if (!Recognition) throw new Error('当前浏览器不支持 Web Speech Recognition')
|
|
41
|
+
|
|
42
|
+
const recognition = new Recognition()
|
|
43
|
+
recognition.lang = this.lang
|
|
44
|
+
recognition.continuous = continuous
|
|
45
|
+
recognition.interimResults = true
|
|
46
|
+
recognition.maxAlternatives = 1
|
|
47
|
+
return recognition
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async speakReceivedPromptAsync() {
|
|
51
|
+
// 固定“收到指令,请您稍等”只负责异步播放,不参与主业务流程门禁。
|
|
52
|
+
// 即使本地语音正在加载/播放,manager.run 也可以立即继续执行。
|
|
53
|
+
if (this.ttsProvider?.speakReceivedCommandAsync) {
|
|
54
|
+
try {
|
|
55
|
+
return await this.ttsProvider.speakReceivedCommandAsync()
|
|
56
|
+
} catch (error) {
|
|
57
|
+
this.onError?.(error?.message || '固定前置提示音播放失败')
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 兼容没有专用 provider 的版本:异步触发,不等待。
|
|
63
|
+
void this.speak('收到指令,请您稍等。', { providerOnly: false }).catch(() => {})
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async speak(text, options = {}) {
|
|
68
|
+
const providerOnly = options?.providerOnly === true
|
|
69
|
+
const waitForEnd = options?.waitForEnd === true
|
|
70
|
+
const value = String(text || '').trim()
|
|
71
|
+
if (!value) return false
|
|
72
|
+
|
|
73
|
+
const isReceivedPrompt = value.replace(/[,。!?、,.!?\s]/g, '') === '收到指令请您稍等'
|
|
74
|
+
|
|
75
|
+
if (this.ttsProvider?.speak) {
|
|
76
|
+
try {
|
|
77
|
+
const spoken = await this.ttsProvider.speak(value, { waitForEnd })
|
|
78
|
+
if (spoken !== false) return true
|
|
79
|
+
// 前置提示音本地资源失败时,直接进入浏览器 TTS 兜底;
|
|
80
|
+
// 不允许再回退到网络 TTS,确保“收到指令”不会因为网络波动丢播。
|
|
81
|
+
if (isReceivedPrompt) return await this.speakWithBrowser(value, { waitForEnd })
|
|
82
|
+
if (providerOnly) {
|
|
83
|
+
throw new Error('第三方 TTS 未成功播放音频')
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// providerOnly=true 仍允许浏览器 TTS 作为兼容兜底。
|
|
87
|
+
// 只有明确要求“绝不兜底”的场景才可在这里抛错;当前小园执行前门禁需要兼容旧浏览器,
|
|
88
|
+
// 因此默认继续走浏览器 speechSynthesis。
|
|
89
|
+
this.onError?.(error?.message || '第三方语音播报失败,正在切换为浏览器语音')
|
|
90
|
+
}
|
|
91
|
+
} else if (providerOnly && !this.canUseBrowserTTS()) {
|
|
92
|
+
throw new Error('TTS Provider 未配置,且当前浏览器不支持内置语音播报')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return await this.speakWithBrowser(value, { waitForEnd })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
canUseBrowserTTS() {
|
|
99
|
+
return typeof window !== 'undefined' &&
|
|
100
|
+
'speechSynthesis' in window &&
|
|
101
|
+
typeof SpeechSynthesisUtterance !== 'undefined'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async speakWithBrowser(text, options = {}) {
|
|
105
|
+
const waitForEnd = options?.waitForEnd === true
|
|
106
|
+
if (!this.canUseBrowserTTS()) return false
|
|
107
|
+
|
|
108
|
+
return await new Promise((resolve) => {
|
|
109
|
+
const synth = window.speechSynthesis
|
|
110
|
+
let started = false
|
|
111
|
+
let settled = false
|
|
112
|
+
let fallbackTimer = null
|
|
113
|
+
|
|
114
|
+
const finish = (value) => {
|
|
115
|
+
if (settled) return
|
|
116
|
+
settled = true
|
|
117
|
+
if (fallbackTimer) window.clearTimeout(fallbackTimer)
|
|
118
|
+
resolve(value)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
synth.cancel()
|
|
123
|
+
try { synth.resume() } catch (_) {}
|
|
124
|
+
|
|
125
|
+
const utterance = new SpeechSynthesisUtterance(String(text))
|
|
126
|
+
utterance.lang = this.lang
|
|
127
|
+
utterance.rate = this.rate
|
|
128
|
+
utterance.pitch = this.pitch
|
|
129
|
+
utterance.volume = this.volume
|
|
130
|
+
if (this.voice) utterance.voice = this.voice
|
|
131
|
+
|
|
132
|
+
// 对小园的执行门禁来说,“真正开始播放”就是 onstart。
|
|
133
|
+
utterance.onstart = () => {
|
|
134
|
+
started = true
|
|
135
|
+
if (!waitForEnd) finish(true)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
utterance.onend = () => {
|
|
139
|
+
// waitForEnd=true 用于 AI 分析结果:必须等整段语音播报完,才能进入下一步。
|
|
140
|
+
if (waitForEnd) {
|
|
141
|
+
finish(started || true)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// 某些浏览器没有稳定触发 onstart,但已经完成了播报。
|
|
145
|
+
// 这种情况下也允许放行,避免工作流永久卡住。
|
|
146
|
+
if (!started) finish(true)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
utterance.onerror = () => finish(false)
|
|
150
|
+
|
|
151
|
+
synth.speak(utterance)
|
|
152
|
+
|
|
153
|
+
window.setTimeout(() => {
|
|
154
|
+
try { synth.resume() } catch (_) {}
|
|
155
|
+
}, 50)
|
|
156
|
+
|
|
157
|
+
// 浏览器偶发不触发 onstart/onend;这里仅作为最后兜底。
|
|
158
|
+
fallbackTimer = window.setTimeout(() => finish(true), Math.max(1200, String(text).length * 120))
|
|
159
|
+
} catch (_) {
|
|
160
|
+
finish(false)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
stopSpeaking() {
|
|
166
|
+
try { this.ttsProvider?.stop?.() } catch (_) {}
|
|
167
|
+
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
|
168
|
+
window.speechSynthesis.cancel()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
listenOnce() {
|
|
173
|
+
return new Promise(async (resolve, reject) => {
|
|
174
|
+
if (!this.supported()) {
|
|
175
|
+
reject(new Error('当前浏览器不支持语音识别'))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await this.requestMicrophonePermission()
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const reason = error?.name || error?.message || error
|
|
183
|
+
reject(new Error(`麦克风权限不可用:${reason}`))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const recognition = this.createRecognition({ continuous: false })
|
|
188
|
+
let finalText = ''
|
|
189
|
+
let settled = false
|
|
190
|
+
|
|
191
|
+
const finish = (error = null) => {
|
|
192
|
+
if (settled) return
|
|
193
|
+
settled = true
|
|
194
|
+
this.listening = false
|
|
195
|
+
this.recognition = null
|
|
196
|
+
this.onEnd()
|
|
197
|
+
if (error) reject(error)
|
|
198
|
+
else resolve(finalText.trim())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
recognition.onstart = () => {
|
|
202
|
+
this.listening = true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
recognition.onresult = (event) => {
|
|
206
|
+
let interim = ''
|
|
207
|
+
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
208
|
+
const part = event.results[i][0]?.transcript || ''
|
|
209
|
+
if (event.results[i].isFinal) {
|
|
210
|
+
finalText += part
|
|
211
|
+
this.onFinal(part)
|
|
212
|
+
} else {
|
|
213
|
+
interim += part
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
this.onInterim(interim)
|
|
217
|
+
|
|
218
|
+
// 已得到 final 文本就尽快结束本轮,减少“说完以后还要等很久”的感觉。
|
|
219
|
+
if (finalText.trim()) {
|
|
220
|
+
window.setTimeout(() => {
|
|
221
|
+
try { recognition.stop() } catch (_) {}
|
|
222
|
+
}, 80)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
recognition.onerror = (event) => {
|
|
227
|
+
const code = event?.error || 'unknown'
|
|
228
|
+
// no-speech 更像“没有听到”,不是系统故障。
|
|
229
|
+
if (code === 'no-speech') {
|
|
230
|
+
finish(new Error('没有听到您的声音,请再说一次。'))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (code === 'not-allowed' || code === 'service-not-allowed') {
|
|
234
|
+
finish(new Error('麦克风权限被拒绝,请允许当前页面使用麦克风后再试。'))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (code === 'audio-capture') {
|
|
238
|
+
finish(new Error('没有检测到可用麦克风,请检查麦克风设备。'))
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (code === 'network') {
|
|
242
|
+
finish(new Error('语音识别网络异常,请检查网络后再试。'))
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
finish(new Error(`语音识别失败:${code}`))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
recognition.onend = () => {
|
|
249
|
+
finish()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
recognition.start()
|
|
254
|
+
this.recognition = recognition
|
|
255
|
+
this.listening = true
|
|
256
|
+
} catch (error) {
|
|
257
|
+
finish(error instanceof Error ? error : new Error('语音识别启动失败'))
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
startWakeWordListener({ wakeWord = '你好小园', onWake, onError }) {
|
|
263
|
+
if (!this.supported()) return { stop: () => {}, pause: () => {}, resume: () => {} }
|
|
264
|
+
|
|
265
|
+
const recognition = this.createRecognition({ continuous: true })
|
|
266
|
+
let buffer = ''
|
|
267
|
+
let stopped = false
|
|
268
|
+
let paused = false
|
|
269
|
+
let restarting = false
|
|
270
|
+
|
|
271
|
+
const normalizedWakeWord = wakeWord.replace(/[,。!?、,.!?\s]/g, '')
|
|
272
|
+
|
|
273
|
+
const restart = () => {
|
|
274
|
+
if (stopped || paused || restarting) return
|
|
275
|
+
restarting = true
|
|
276
|
+
window.setTimeout(() => {
|
|
277
|
+
restarting = false
|
|
278
|
+
if (stopped) return
|
|
279
|
+
try { recognition.start() } catch (_) {}
|
|
280
|
+
}, 300)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
recognition.onresult = (event) => {
|
|
284
|
+
let text = ''
|
|
285
|
+
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
286
|
+
text += event.results[i][0]?.transcript || ''
|
|
287
|
+
}
|
|
288
|
+
buffer = `${buffer}${text}`.replace(/\s+/g, '')
|
|
289
|
+
const normalized = buffer.replace(/[,。!?、,.!?]/g, '')
|
|
290
|
+
if (normalized.includes(normalizedWakeWord)) {
|
|
291
|
+
buffer = ''
|
|
292
|
+
onWake?.()
|
|
293
|
+
} else if (buffer.length > 80) {
|
|
294
|
+
buffer = buffer.slice(-80)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
recognition.onerror = (event) => {
|
|
299
|
+
const code = event?.error || 'unknown'
|
|
300
|
+
// 唤醒监听失败不连续弹错误消息,避免屏幕不断出现“语音识别失败”。
|
|
301
|
+
if (code !== 'no-speech') onError?.(code)
|
|
302
|
+
restart()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
recognition.onend = restart
|
|
306
|
+
|
|
307
|
+
const controller = {
|
|
308
|
+
stop() {
|
|
309
|
+
stopped = true
|
|
310
|
+
recognition.onend = null
|
|
311
|
+
try { recognition.stop() } catch (_) {}
|
|
312
|
+
buffer = ''
|
|
313
|
+
},
|
|
314
|
+
pause() {
|
|
315
|
+
paused = true
|
|
316
|
+
try { recognition.stop() } catch (_) {}
|
|
317
|
+
},
|
|
318
|
+
resume() {
|
|
319
|
+
if (stopped) return
|
|
320
|
+
paused = false
|
|
321
|
+
try { recognition.start() } catch (_) {}
|
|
322
|
+
},
|
|
323
|
+
recognition
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try { recognition.start() } catch (_) {}
|
|
327
|
+
return controller
|
|
328
|
+
}
|
|
329
|
+
}
|