@dssp/supervision 1.0.0-alpha.52 → 1.0.0-alpha.53

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.
@@ -1,15 +1,16 @@
1
- import { __decorate } from "tslib";
1
+ import { __decorate, __metadata } from "tslib";
2
2
  import { LitElement } from 'lit';
3
- import { customElement } from 'lit/decorators.js';
3
+ import { customElement, property } from 'lit/decorators.js';
4
4
  /**
5
5
  * SpeechToText (음성 인식) 웹 컴포넌트
6
6
  *
7
7
  * - 음성 인식 시작/중지 메서드 제공
8
8
  * - 인식 결과/에러/상태를 커스텀 이벤트(stt-result, stt-error, stt-status)로 외부에 전달
9
9
  * - 버튼 등 UI는 포함하지 않음 (컨트롤은 외부에서)
10
+ * - Google Cloud Speech API 사용
10
11
  *
11
12
  * 사용 예시:
12
- * <speech-to-text id="stt"></speech-to-text>
13
+ * <speech-to-text id="stt" api-key="YOUR_API_KEY"></speech-to-text>
13
14
  *
14
15
  * // JS에서
15
16
  * const stt = document.getElementById('stt') as SpeechToText;
@@ -22,66 +23,307 @@ import { customElement } from 'lit/decorators.js';
22
23
  let SpeechToText = class SpeechToText extends LitElement {
23
24
  constructor() {
24
25
  super(...arguments);
25
- this.recognition = null;
26
+ this.apiKey = 'AIzaSyCH3FPpY5ZCpcfUMjePH2Nhtueu6chgBDk';
27
+ this.mediaRecorder = null;
28
+ this.audioChunks = [];
29
+ this.isRecording = false;
30
+ this.stream = null;
31
+ this.isProcessing = false;
26
32
  }
27
33
  /**
28
34
  * 음성 인식 시작
29
35
  */
30
- startListening() {
31
- if (!('webkitSpeechRecognition' in window)) {
32
- this.dispatchEvent(new CustomEvent('stt-error', { detail: { error: '지원되지 않는 브라우저입니다.' } }));
36
+ async startListening() {
37
+ if (!this.apiKey) {
38
+ this.dispatchEvent(new CustomEvent('stt-error', {
39
+ detail: { error: 'Google Cloud API key가 설정되지 않았습니다.' }
40
+ }));
41
+ return;
42
+ }
43
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
44
+ this.dispatchEvent(new CustomEvent('stt-error', {
45
+ detail: { error: '지원되지 않는 브라우저입니다.' }
46
+ }));
33
47
  return;
34
48
  }
35
- this.recognition = new window.webkitSpeechRecognition();
36
- this.recognition.lang = 'ko-KR';
37
- this.recognition.continuous = false;
38
- this.recognition.interimResults = true;
39
- this.recognition.onresult = (event) => {
40
- let finalTranscript = '';
41
- let interimTranscript = '';
42
- for (let i = event.resultIndex; i < event.results.length; i++) {
43
- if (event.results[i].isFinal) {
44
- finalTranscript += event.results[i][0].transcript;
49
+ if (this.isRecording || this.isProcessing) {
50
+ return;
51
+ }
52
+ // 이전 상태 완전 정리
53
+ this.cleanup();
54
+ try {
55
+ this.stream = await navigator.mediaDevices.getUserMedia({
56
+ audio: {
57
+ echoCancellation: true,
58
+ noiseSuppression: true,
59
+ autoGainControl: true,
60
+ sampleRate: 48000
45
61
  }
46
- else {
47
- interimTranscript += event.results[i][0].transcript;
62
+ });
63
+ let mimeType = 'audio/webm;codecs=opus';
64
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
65
+ mimeType = 'audio/webm';
66
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
67
+ if (MediaRecorder.isTypeSupported('audio/mp4')) {
68
+ mimeType = 'audio/mp4';
69
+ }
70
+ else if (MediaRecorder.isTypeSupported('audio/wav')) {
71
+ mimeType = 'audio/wav';
72
+ }
73
+ else {
74
+ mimeType = '';
75
+ }
48
76
  }
49
77
  }
50
- this.dispatchEvent(new CustomEvent('stt-result', {
51
- detail: {
52
- finalTranscript: finalTranscript.trim(),
53
- interimTranscript: interimTranscript.trim()
78
+ const recorderOptions = mimeType ? { mimeType } : {};
79
+ this.mediaRecorder = new MediaRecorder(this.stream, recorderOptions);
80
+ this.audioChunks = [];
81
+ this.isRecording = true;
82
+ this.mediaRecorder.ondataavailable = event => {
83
+ if (event.data.size > 0) {
84
+ this.audioChunks.push(event.data);
54
85
  }
55
- }));
86
+ };
87
+ this.mediaRecorder.onstop = async () => {
88
+ const audioBlob = new Blob(this.audioChunks, { type: mimeType || 'audio/webm' });
89
+ if (audioBlob.size > 5000) {
90
+ await this.processAudio(audioBlob);
91
+ }
92
+ else {
93
+ this.dispatchEvent(new CustomEvent('stt-error', {
94
+ detail: { error: '녹음이 너무 짧습니다.' }
95
+ }));
96
+ // 녹음이 짧을 때도 완료 처리
97
+ this.finishRecording();
98
+ }
99
+ this.cleanup();
100
+ };
101
+ this.mediaRecorder.onerror = () => {
102
+ this.dispatchEvent(new CustomEvent('stt-error', {
103
+ detail: { error: '녹음 오류 발생' }
104
+ }));
105
+ this.isRecording = false;
106
+ };
107
+ this.mediaRecorder.start();
56
108
  this.dispatchEvent(new CustomEvent('stt-status', {
57
- detail: { status: `📝 Recognized: ${finalTranscript} ${interimTranscript}` }
109
+ detail: { status: '🎙️ Listening...' }
58
110
  }));
59
- };
60
- this.recognition.onerror = (event) => {
61
- this.dispatchEvent(new CustomEvent('stt-error', { detail: { error: event.error } }));
62
- };
63
- this.recognition.onend = () => {
64
- this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '음성 인식 종료' } }));
65
- };
66
- this.recognition.onstart = () => {
67
- this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '🎙️ Listening...' } }));
68
- };
69
- this.recognition.start();
111
+ }
112
+ catch (error) {
113
+ this.dispatchEvent(new CustomEvent('stt-error', {
114
+ detail: { error: `마이크 접근 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}` }
115
+ }));
116
+ this.isRecording = false;
117
+ }
70
118
  }
71
119
  /**
72
120
  * 음성 인식 중지
73
121
  */
74
122
  stopListening() {
75
- if (this.recognition) {
76
- this.recognition.stop();
77
- this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '🛑 Stopped listening.' } }));
123
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
124
+ this.mediaRecorder.stop();
125
+ }
126
+ else {
127
+ // MediaRecorder가 없거나 이미 중지된 경우 즉시 정리
128
+ this.cleanup();
129
+ this.dispatchEvent(new CustomEvent('stt-status', {
130
+ detail: { status: '음성 인식 종료' }
131
+ }));
132
+ }
133
+ }
134
+ async processAudio(audioBlob) {
135
+ this.isProcessing = true;
136
+ this.dispatchEvent(new CustomEvent('stt-status', {
137
+ detail: { status: '🔄 음성을 텍스트로 변환 중...' }
138
+ }));
139
+ try {
140
+ const reader = new FileReader();
141
+ reader.readAsDataURL(audioBlob);
142
+ reader.onloadend = async () => {
143
+ const base64Audio = reader.result.split(',')[1];
144
+ await this.sendToGoogleSpeech(base64Audio);
145
+ };
146
+ reader.onerror = () => {
147
+ this.dispatchEvent(new CustomEvent('stt-error', {
148
+ detail: { error: '오디오 처리 실패' }
149
+ }));
150
+ this.finishRecording();
151
+ };
152
+ }
153
+ catch (error) {
154
+ this.dispatchEvent(new CustomEvent('stt-error', {
155
+ detail: { error: '처리 중 오류 발생' }
156
+ }));
157
+ this.finishRecording();
158
+ }
159
+ }
160
+ async sendToGoogleSpeech(base64Audio) {
161
+ let encoding = 'WEBM_OPUS';
162
+ let sampleRate = 48000;
163
+ if (this.mediaRecorder && this.mediaRecorder.mimeType) {
164
+ const mimeType = this.mediaRecorder.mimeType.toLowerCase();
165
+ if (mimeType.includes('opus')) {
166
+ encoding = 'WEBM_OPUS';
167
+ }
168
+ else if (mimeType.includes('webm')) {
169
+ encoding = 'WEBM_OPUS';
170
+ }
171
+ else if (mimeType.includes('mp4')) {
172
+ encoding = 'MP3';
173
+ }
174
+ else if (mimeType.includes('wav')) {
175
+ encoding = 'LINEAR16';
176
+ }
177
+ }
178
+ const requestBody = {
179
+ config: {
180
+ encoding: encoding,
181
+ sampleRateHertz: sampleRate,
182
+ languageCode: 'ko-KR',
183
+ enableAutomaticPunctuation: true,
184
+ model: 'latest_long',
185
+ useEnhanced: true,
186
+ enableWordTimeOffsets: false,
187
+ enableWordConfidence: false,
188
+ maxAlternatives: 1,
189
+ speechContexts: [
190
+ {
191
+ phrases: [
192
+ '1번',
193
+ '2번',
194
+ '3번',
195
+ '4번',
196
+ '5번',
197
+ '6번',
198
+ '7번',
199
+ '합격',
200
+ '불합격',
201
+ '적합',
202
+ '부적합',
203
+ '기본업무',
204
+ '시공',
205
+ '자재',
206
+ '도서',
207
+ '모두',
208
+ '전체',
209
+ '초기화',
210
+ '조치사항',
211
+ '확인 필요',
212
+ '기본 외 업무',
213
+ '부터',
214
+ '까지'
215
+ ],
216
+ boost: 20
217
+ }
218
+ ]
219
+ },
220
+ audio: {
221
+ content: base64Audio
222
+ }
223
+ };
224
+ try {
225
+ const response = await fetch(`https://speech.googleapis.com/v1/speech:recognize?key=${this.apiKey}`, {
226
+ method: 'POST',
227
+ headers: {
228
+ 'Content-Type': 'application/json'
229
+ },
230
+ body: JSON.stringify(requestBody)
231
+ });
232
+ if (response.ok) {
233
+ const data = await response.json();
234
+ if (data.results && data.results.length > 0) {
235
+ const finalTranscript = data.results.map((result) => result.alternatives[0].transcript).join(' ');
236
+ this.dispatchEvent(new CustomEvent('stt-result', {
237
+ detail: {
238
+ finalTranscript: finalTranscript.trim(),
239
+ interimTranscript: ''
240
+ }
241
+ }));
242
+ this.dispatchEvent(new CustomEvent('stt-status', {
243
+ detail: { status: `📝 Recognized: ${finalTranscript}` }
244
+ }));
245
+ // 음성 인식 완료 후 즉시 완료 처리
246
+ this.finishRecording();
247
+ }
248
+ else {
249
+ this.dispatchEvent(new CustomEvent('stt-result', {
250
+ detail: {
251
+ finalTranscript: '',
252
+ interimTranscript: ''
253
+ }
254
+ }));
255
+ this.dispatchEvent(new CustomEvent('stt-status', {
256
+ detail: { status: '음성을 인식하지 못했습니다.' }
257
+ }));
258
+ // 인식 실패 시에도 즉시 완료 처리
259
+ this.finishRecording();
260
+ }
261
+ }
262
+ else {
263
+ const error = await response.text();
264
+ let errorMessage = 'API 오류 발생';
265
+ try {
266
+ const errorObj = JSON.parse(error);
267
+ if (errorObj.error && errorObj.error.message) {
268
+ errorMessage = errorObj.error.message;
269
+ }
270
+ }
271
+ catch (e) {
272
+ errorMessage = `HTTP ${response.status}`;
273
+ }
274
+ this.dispatchEvent(new CustomEvent('stt-error', {
275
+ detail: { error: errorMessage }
276
+ }));
277
+ // API 오류 시에도 즉시 완료 처리
278
+ this.finishRecording();
279
+ }
280
+ }
281
+ catch (error) {
282
+ this.dispatchEvent(new CustomEvent('stt-error', {
283
+ detail: { error: `네트워크 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}` }
284
+ }));
285
+ // 네트워크 오류 시에도 즉시 완료 처리
286
+ this.finishRecording();
287
+ }
288
+ }
289
+ finishRecording() {
290
+ this.cleanup();
291
+ this.dispatchEvent(new CustomEvent('stt-status', {
292
+ detail: { status: '음성 인식 종료' }
293
+ }));
294
+ }
295
+ cleanup() {
296
+ // 모든 상태 완전 초기화
297
+ if (this.stream) {
298
+ this.stream.getTracks().forEach(track => {
299
+ track.stop();
300
+ track.enabled = false;
301
+ });
302
+ this.stream = null;
303
+ }
304
+ if (this.mediaRecorder) {
305
+ try {
306
+ if (this.mediaRecorder.state !== 'inactive') {
307
+ this.mediaRecorder.stop();
308
+ }
309
+ }
310
+ catch (e) {
311
+ // MediaRecorder stop 에러 무시
312
+ }
313
+ this.mediaRecorder = null;
78
314
  }
315
+ this.audioChunks = [];
316
+ this.isRecording = false;
317
+ this.isProcessing = false;
79
318
  }
80
- // UI 없음 (렌더링 X)
81
319
  render() {
82
320
  return null;
83
321
  }
84
322
  };
323
+ __decorate([
324
+ property({ type: String, attribute: 'api-key' }),
325
+ __metadata("design:type", String)
326
+ ], SpeechToText.prototype, "apiKey", void 0);
85
327
  SpeechToText = __decorate([
86
328
  customElement('speech-to-text')
87
329
  ], SpeechToText);
@@ -1 +1 @@
1
- {"version":3,"file":"speech-to-text.js","sourceRoot":"","sources":["../../client/stt/speech-to-text.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement } from 'lit'\nimport { customElement } from 'lit/decorators.js'\n\n/**\n * SpeechToText (음성 인식) 웹 컴포넌트\n *\n * - 음성 인식 시작/중지 메서드 제공\n * - 인식 결과/에러/상태를 커스텀 이벤트(stt-result, stt-error, stt-status)로 외부에 전달\n * - 버튼 등 UI는 포함하지 않음 (컨트롤은 외부에서)\n *\n * 사용 예시:\n * <speech-to-text id=\"stt\"></speech-to-text>\n *\n * // JS에서\n * const stt = document.getElementById('stt') as SpeechToText;\n * stt.startListening();\n * stt.stopListening();\n * stt.addEventListener('stt-result', e => { ... });\n * stt.addEventListener('stt-error', e => { ... });\n * stt.addEventListener('stt-status', e => { ... });\n */\n@customElement('speech-to-text')\nexport class SpeechToText extends LitElement {\n private recognition: any = null\n\n /**\n * 음성 인식 시작\n */\n public startListening() {\n if (!('webkitSpeechRecognition' in window)) {\n this.dispatchEvent(new CustomEvent('stt-error', { detail: { error: '지원되지 않는 브라우저입니다.' } }))\n return\n }\n this.recognition = new window.webkitSpeechRecognition()\n this.recognition.lang = 'ko-KR'\n this.recognition.continuous = false\n this.recognition.interimResults = true\n\n this.recognition.onresult = (event: any) => {\n let finalTranscript = ''\n let interimTranscript = ''\n for (let i = event.resultIndex; i < event.results.length; i++) {\n if (event.results[i].isFinal) {\n finalTranscript += event.results[i][0].transcript\n } else {\n interimTranscript += event.results[i][0].transcript\n }\n }\n this.dispatchEvent(\n new CustomEvent('stt-result', {\n detail: {\n finalTranscript: finalTranscript.trim(),\n interimTranscript: interimTranscript.trim()\n }\n })\n )\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: `📝 Recognized: ${finalTranscript} ${interimTranscript}` }\n })\n )\n }\n\n this.recognition.onerror = (event: any) => {\n this.dispatchEvent(new CustomEvent('stt-error', { detail: { error: event.error } }))\n }\n\n this.recognition.onend = () => {\n this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '음성 인식 종료' } }))\n }\n\n this.recognition.onstart = () => {\n this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '🎙️ Listening...' } }))\n }\n\n this.recognition.start()\n }\n\n /**\n * 음성 인식 중지\n */\n public stopListening() {\n if (this.recognition) {\n this.recognition.stop()\n this.dispatchEvent(new CustomEvent('stt-status', { detail: { status: '🛑 Stopped listening.' } }))\n }\n }\n\n // UI 없음 (렌더링 X)\n protected render() {\n return null\n }\n}\n\n// 타입 정의 (window에 webkitSpeechRecognition이 있는 경우)\ndeclare global {\n interface Window {\n webkitSpeechRecognition?: any\n }\n}\n"]}
1
+ {"version":3,"file":"speech-to-text.js","sourceRoot":"","sources":["../../client/stt/speech-to-text.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\n/**\n * SpeechToText (음성 인식) 웹 컴포넌트\n *\n * - 음성 인식 시작/중지 메서드 제공\n * - 인식 결과/에러/상태를 커스텀 이벤트(stt-result, stt-error, stt-status)로 외부에 전달\n * - 버튼 등 UI는 포함하지 않음 (컨트롤은 외부에서)\n * - Google Cloud Speech API 사용\n *\n * 사용 예시:\n * <speech-to-text id=\"stt\" api-key=\"YOUR_API_KEY\"></speech-to-text>\n *\n * // JS에서\n * const stt = document.getElementById('stt') as SpeechToText;\n * stt.startListening();\n * stt.stopListening();\n * stt.addEventListener('stt-result', e => { ... });\n * stt.addEventListener('stt-error', e => { ... });\n * stt.addEventListener('stt-status', e => { ... });\n */\n@customElement('speech-to-text')\nexport class SpeechToText extends LitElement {\n @property({ type: String, attribute: 'api-key' })\n apiKey: string = 'AIzaSyCH3FPpY5ZCpcfUMjePH2Nhtueu6chgBDk'\n\n private mediaRecorder: MediaRecorder | null = null\n private audioChunks: Blob[] = []\n private isRecording: boolean = false\n private stream: MediaStream | null = null\n private isProcessing: boolean = false\n\n /**\n * 음성 인식 시작\n */\n public async startListening() {\n if (!this.apiKey) {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: 'Google Cloud API key가 설정되지 않았습니다.' }\n })\n )\n return\n }\n\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: '지원되지 않는 브라우저입니다.' }\n })\n )\n return\n }\n\n if (this.isRecording || this.isProcessing) {\n return\n }\n\n // 이전 상태 완전 정리\n this.cleanup()\n\n try {\n this.stream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n sampleRate: 48000\n }\n })\n\n let mimeType = 'audio/webm;codecs=opus'\n if (!MediaRecorder.isTypeSupported(mimeType)) {\n mimeType = 'audio/webm'\n if (!MediaRecorder.isTypeSupported(mimeType)) {\n if (MediaRecorder.isTypeSupported('audio/mp4')) {\n mimeType = 'audio/mp4'\n } else if (MediaRecorder.isTypeSupported('audio/wav')) {\n mimeType = 'audio/wav'\n } else {\n mimeType = ''\n }\n }\n }\n\n const recorderOptions = mimeType ? { mimeType } : {}\n this.mediaRecorder = new MediaRecorder(this.stream, recorderOptions)\n\n this.audioChunks = []\n this.isRecording = true\n\n this.mediaRecorder.ondataavailable = event => {\n if (event.data.size > 0) {\n this.audioChunks.push(event.data)\n }\n }\n\n this.mediaRecorder.onstop = async () => {\n const audioBlob = new Blob(this.audioChunks, { type: mimeType || 'audio/webm' })\n\n if (audioBlob.size > 5000) {\n await this.processAudio(audioBlob)\n } else {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: '녹음이 너무 짧습니다.' }\n })\n )\n // 녹음이 짧을 때도 완료 처리\n this.finishRecording()\n }\n\n this.cleanup()\n }\n\n this.mediaRecorder.onerror = () => {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: '녹음 오류 발생' }\n })\n )\n this.isRecording = false\n }\n\n this.mediaRecorder.start()\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: '🎙️ Listening...' }\n })\n )\n } catch (error) {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: `마이크 접근 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}` }\n })\n )\n this.isRecording = false\n }\n }\n\n /**\n * 음성 인식 중지\n */\n public stopListening() {\n if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {\n this.mediaRecorder.stop()\n } else {\n // MediaRecorder가 없거나 이미 중지된 경우 즉시 정리\n this.cleanup()\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: '음성 인식 종료' }\n })\n )\n }\n }\n\n private async processAudio(audioBlob: Blob): Promise<void> {\n this.isProcessing = true\n\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: '🔄 음성을 텍스트로 변환 중...' }\n })\n )\n\n try {\n const reader = new FileReader()\n reader.readAsDataURL(audioBlob)\n\n reader.onloadend = async () => {\n const base64Audio = (reader.result as string).split(',')[1]\n await this.sendToGoogleSpeech(base64Audio)\n }\n\n reader.onerror = () => {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: '오디오 처리 실패' }\n })\n )\n this.finishRecording()\n }\n } catch (error) {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: '처리 중 오류 발생' }\n })\n )\n this.finishRecording()\n }\n }\n\n private async sendToGoogleSpeech(base64Audio: string): Promise<void> {\n let encoding = 'WEBM_OPUS'\n let sampleRate = 48000\n\n if (this.mediaRecorder && this.mediaRecorder.mimeType) {\n const mimeType = this.mediaRecorder.mimeType.toLowerCase()\n\n if (mimeType.includes('opus')) {\n encoding = 'WEBM_OPUS'\n } else if (mimeType.includes('webm')) {\n encoding = 'WEBM_OPUS'\n } else if (mimeType.includes('mp4')) {\n encoding = 'MP3'\n } else if (mimeType.includes('wav')) {\n encoding = 'LINEAR16'\n }\n }\n\n const requestBody = {\n config: {\n encoding: encoding,\n sampleRateHertz: sampleRate,\n languageCode: 'ko-KR',\n enableAutomaticPunctuation: true,\n model: 'latest_long',\n useEnhanced: true,\n enableWordTimeOffsets: false,\n enableWordConfidence: false,\n maxAlternatives: 1,\n speechContexts: [\n {\n phrases: [\n '1번',\n '2번',\n '3번',\n '4번',\n '5번',\n '6번',\n '7번',\n '합격',\n '불합격',\n '적합',\n '부적합',\n '기본업무',\n '시공',\n '자재',\n '도서',\n '모두',\n '전체',\n '초기화',\n '조치사항',\n '확인 필요',\n '기본 외 업무',\n '부터',\n '까지'\n ],\n boost: 20\n }\n ]\n },\n audio: {\n content: base64Audio\n }\n }\n\n try {\n const response = await fetch(`https://speech.googleapis.com/v1/speech:recognize?key=${this.apiKey}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(requestBody)\n })\n\n if (response.ok) {\n const data = await response.json()\n\n if (data.results && data.results.length > 0) {\n const finalTranscript = data.results.map((result: any) => result.alternatives[0].transcript).join(' ')\n\n this.dispatchEvent(\n new CustomEvent('stt-result', {\n detail: {\n finalTranscript: finalTranscript.trim(),\n interimTranscript: ''\n }\n })\n )\n\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: `📝 Recognized: ${finalTranscript}` }\n })\n )\n\n // 음성 인식 완료 후 즉시 완료 처리\n this.finishRecording()\n } else {\n this.dispatchEvent(\n new CustomEvent('stt-result', {\n detail: {\n finalTranscript: '',\n interimTranscript: ''\n }\n })\n )\n\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: '음성을 인식하지 못했습니다.' }\n })\n )\n\n // 인식 실패 시에도 즉시 완료 처리\n this.finishRecording()\n }\n } else {\n const error = await response.text()\n let errorMessage = 'API 오류 발생'\n\n try {\n const errorObj = JSON.parse(error)\n if (errorObj.error && errorObj.error.message) {\n errorMessage = errorObj.error.message\n }\n } catch (e) {\n errorMessage = `HTTP ${response.status}`\n }\n\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: errorMessage }\n })\n )\n\n // API 오류 시에도 즉시 완료 처리\n this.finishRecording()\n }\n } catch (error) {\n this.dispatchEvent(\n new CustomEvent('stt-error', {\n detail: { error: `네트워크 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}` }\n })\n )\n\n // 네트워크 오류 시에도 즉시 완료 처리\n this.finishRecording()\n }\n }\n\n private finishRecording() {\n this.cleanup()\n\n this.dispatchEvent(\n new CustomEvent('stt-status', {\n detail: { status: '음성 인식 종료' }\n })\n )\n }\n\n private cleanup() {\n // 모든 상태 완전 초기화\n if (this.stream) {\n this.stream.getTracks().forEach(track => {\n track.stop()\n track.enabled = false\n })\n this.stream = null\n }\n\n if (this.mediaRecorder) {\n try {\n if (this.mediaRecorder.state !== 'inactive') {\n this.mediaRecorder.stop()\n }\n } catch (e) {\n // MediaRecorder stop 에러 무시\n }\n this.mediaRecorder = null\n }\n\n this.audioChunks = []\n this.isRecording = false\n this.isProcessing = false\n }\n\n protected render() {\n return null\n }\n}\n"]}