Oviya commited on
Commit
e20fbf3
·
1 Parent(s): 61c407f

update pronounciation

Browse files
src/app/pronunciation/pronunciation.component.css ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .pron-container {
2
+ display: flex;
3
+ padding: 2vw;
4
+ font-family: Raleway, Roboto, Helvetica Neue, sans-serif;
5
+ border: 10px solid #009688;
6
+ height: 100%;
7
+ border-radius: 1vw;
8
+ flex-direction: column;
9
+ }
10
+
11
+ .header {
12
+ text-align: center;
13
+ }
14
+
15
+ .title {
16
+ font-size: 2.5vw;
17
+ color: #006780;
18
+ margin-bottom: 1vw;
19
+ font-weight: 800;
20
+ font-family: Raleway, Roboto, Helvetica Neue, sans-serif;
21
+ }
22
+
23
+ .main-content {
24
+ display: flex;
25
+ gap: 5vw;
26
+ }
27
+
28
+ .image-section {
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ width: 20vw;
33
+ }
34
+
35
+ .apple-image {
36
+ width: 20vw;
37
+ height: 20vw;
38
+ object-fit: contain;
39
+ filter: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));
40
+ animation: float 3s ease-in-out infinite;
41
+ }
42
+
43
+ @keyframes float {
44
+ 0%, 100% {
45
+ transform: translateY(0px);
46
+ }
47
+
48
+ 50% {
49
+ transform: translateY(-20px);
50
+ }
51
+ }
52
+
53
+ .controls-section {
54
+ display: flex;
55
+ flex-direction: column;
56
+ justify-content: center;
57
+ gap: 2vw;
58
+ width: 30vw;
59
+ }
60
+
61
+ .word-section {
62
+ text-align: center;
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 1vw;
66
+ }
67
+
68
+ .word {
69
+ font-size: 3rem;
70
+ font-weight: bold;
71
+ color: hsl(222, 47%, 11%);
72
+ text-transform: capitalize;
73
+ margin-bottom: 0.5rem;
74
+ font-family: Raleway, Roboto, Helvetica Neue, sans-serif;
75
+ }
76
+
77
+ .phonetics {
78
+ font-size: 1.5vw;
79
+ color: hsl(215, 16%, 47%);
80
+ font-family: Raleway, Roboto, Helvetica Neue, sans-serif;
81
+ }
82
+
83
+ .teacher-section {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 1rem;
87
+ padding: 1rem;
88
+ background: hsla(217, 91%, 60%, 0.1);
89
+ border-radius: 1rem;
90
+ border: 2px solid hsla(217, 91%, 60%, 0.2);
91
+ }
92
+
93
+ .student-section {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 1rem;
97
+ padding: 1rem;
98
+ background: hsla(142, 76%, 36%, 0.1);
99
+ border-radius: 1rem;
100
+ border: 2px solid hsla(142, 76%, 36%, 0.2);
101
+ }
102
+
103
+ .avatar {
104
+ width: 3rem;
105
+ height: 3rem;
106
+ border-radius: 9999px;
107
+ object-fit: cover;
108
+ }
109
+
110
+ .button-group {
111
+ display: flex;
112
+ gap: 0.5rem;
113
+ flex-wrap: wrap;
114
+ }
115
+
116
+ .btn {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ gap: 0.5rem;
121
+ padding: 0.8vw;
122
+ font-size: 1rem;
123
+ font-weight: 500;
124
+ border-radius: 0.5rem;
125
+ border: none;
126
+ cursor: pointer;
127
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
128
+ position: relative;
129
+ box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.2);
130
+ width: 11vw;
131
+ }
132
+
133
+ .btn:hover {
134
+ transform: scale(1.05);
135
+ }
136
+
137
+ .btn:active {
138
+ transform: scale(0.95);
139
+ }
140
+
141
+ .btn:disabled {
142
+ opacity: 0.6;
143
+ cursor: not-allowed;
144
+ transform: none;
145
+ }
146
+
147
+ .btn-primary {
148
+ background: hsl(217, 91%, 60%);
149
+ color: white;
150
+ }
151
+
152
+ .btn-secondary {
153
+ background: hsl(142, 76%, 36%);
154
+ color: white;
155
+ }
156
+
157
+ .btn-accent {
158
+ background: hsl(280, 100%, 70%);
159
+ color: white;
160
+ }
161
+
162
+ .btn-success {
163
+ background: hsl(142, 76%, 36%);
164
+ color: white;
165
+ }
166
+
167
+ .btn-outline {
168
+ background: transparent;
169
+ border: 2px solid hsl(214, 32%, 91%);
170
+ color: hsl(222, 47%, 11%);
171
+ }
172
+
173
+ .full-width {
174
+ width: 100%;
175
+ }
176
+
177
+ .icon {
178
+ width: 1.25rem;
179
+ height: 1.25rem;
180
+ }
181
+
182
+ .spinner {
183
+ width: 1.25rem;
184
+ height: 1.25rem;
185
+ animation: spin 1s linear infinite;
186
+ }
187
+
188
+ .spinner-circle {
189
+ fill: none;
190
+ stroke: currentColor;
191
+ stroke-width: 3;
192
+ stroke-dasharray: 50;
193
+ stroke-dashoffset: 25;
194
+ }
195
+
196
+ @keyframes spin {
197
+ to {
198
+ transform: rotate(360deg);
199
+ }
200
+ }
201
+
202
+ .hidden {
203
+ display: none !important;
204
+ }
205
+
206
+ .results-section {
207
+ width: 30vw;
208
+ height: 30vw;
209
+ display: flex;
210
+ flex-direction: column;
211
+ align-items: center;
212
+ justify-content: space-between;
213
+ }
214
+
215
+ .gauge-wrapper {
216
+ position: relative;
217
+ width: 20vw;
218
+ height: 10vw;
219
+ }
220
+
221
+ .gauge {
222
+ position: absolute;
223
+ left: 50%;
224
+ top: 0;
225
+ transform: translateX(-50%);
226
+ width: 100%;
227
+ height: 100%;
228
+ border-radius: 260px 260px 0 0;
229
+ overflow: hidden;
230
+ background: #f3f3f3;
231
+ box-shadow: 0 4px 10px rgba(0,0,0,0.25) inset;
232
+ }
233
+
234
+ .gauge-arc {
235
+ position: absolute;
236
+ inset: 0;
237
+ border-radius: 50%;
238
+ background: conic-gradient( from 270deg, #e53935 0deg 45deg, #fb8c00 45deg 90deg, #fbc02d 90deg 135deg, #43a047 135deg 180deg, transparent 180deg 360deg );
239
+ height: 20vw;
240
+ }
241
+
242
+ .needle {
243
+ position: absolute;
244
+ bottom: 0vw;
245
+ left: 50%;
246
+ width: 0.7vw;
247
+ height: 8vw;
248
+ background: #333;
249
+ transform: translateX(-50%) rotate(var(--angle, -90deg));
250
+ transform-origin: 50% 100%;
251
+ transition: transform 700ms cubic-bezier(.2,.9,.2,1);
252
+ border-radius: 10px;
253
+ box-shadow: 0 2px 6px rgba(0,0,0,0.5);
254
+ }
255
+
256
+ .mic-badge {
257
+ position: absolute;
258
+ bottom: -0.3vw;
259
+ left: 50%;
260
+ transform: translate(-50%, 35%);
261
+ width: 3vw;
262
+ height: 3vw;
263
+ border-radius: 50%;
264
+ background: #000;
265
+ box-shadow: 0 8px 18px rgba(0,0,0,0.4);
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ }
270
+
271
+ .score-span {
272
+ color: white;
273
+ font-size: 1vw;
274
+ font-weight: bold;
275
+ }
276
+
277
+ .nav-buttons {
278
+ position: absolute;
279
+ right: 2rem;
280
+ bottom: 2rem;
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 0.75rem;
284
+ z-index: 30;
285
+ }
286
+
287
+ .btn-nav {
288
+ padding: 0.5rem 0.75rem;
289
+ font-size: 1vw;
290
+ white-space: nowrap;
291
+ background: #009688;
292
+ width: 7vw;
293
+ height: 2.5vw;
294
+ font-weight: bold;
295
+ color: white;
296
+ }
297
+
298
+ .nav-info {
299
+ font-weight: 600;
300
+ color: hsl(222, 47%, 11%);
301
+ min-width: 4.5rem;
302
+ text-align: center;
303
+ font-size: 1vw;
304
+ }
305
+
306
+ .suggestions-section {
307
+ background-color: azure;
308
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
309
+ padding: 1vw;
310
+ }
311
+
312
+ .suggestions-title {
313
+ font-size: 1.25rem;
314
+ font-weight: 600;
315
+ color: hsl(222, 47%, 11%);
316
+ margin-bottom: 1rem;
317
+ font-family: Raleway, Roboto, Helvetica Neue, sans-serif;
318
+ }
319
+
320
+ .suggestions-page {
321
+ display: flex;
322
+ gap: 1vw;
323
+ flex-direction: column;
324
+ }
325
+
326
+ .suggestion-card {
327
+ display: flex;
328
+ gap: 1rem;
329
+ align-items: flex-start;
330
+ background: #fff;
331
+ border-radius: 12px;
332
+ padding: 1rem 1.25rem;
333
+ box-shadow: 0 6px 18px rgba(10, 30, 60, 0.06);
334
+ border: 1px solid rgba(0,0,0,0.04);
335
+ transition: transform .15s ease, box-shadow .15s ease;
336
+ }
337
+
338
+ .suggestion-card:hover {
339
+ transform: translateY(-4px);
340
+ box-shadow: 0 10px 30px rgba(10, 30, 60, 0.08);
341
+ }
342
+
343
+ .suggestion-badge {
344
+ min-width: 36px;
345
+ height: 36px;
346
+ border-radius: 999px;
347
+ background: linear-gradient(180deg, #eaf3ff, #dbeeff);
348
+ color: #0b57a4;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ font-weight: 700;
353
+ box-shadow: 0 4px 10px rgba(11,87,164,0.08);
354
+ flex-shrink: 0;
355
+ font-size: 0.95rem;
356
+ }
357
+
358
+ .suggestion-body {
359
+ flex: 1;
360
+ }
361
+
362
+ .suggestion-point {
363
+ font-size: 1rem;
364
+ font-weight: 700;
365
+ color: hsl(222, 47%, 11%);
366
+ margin-bottom: 0.25rem;
367
+ }
368
+
369
+ .suggestion-feedback {
370
+ color: hsl(215, 16%, 47%);
371
+ font-size: 0.95rem;
372
+ line-height: 1.45;
373
+ }
374
+
375
+ .suggestion-nav {
376
+ display: flex;
377
+ align-items: center;
378
+ justify-content: center;
379
+ padding:1vw;
380
+ }
381
+
382
+
383
+ /* Existing .voice-selection-container */
384
+ .voice-selection-container {
385
+ display: flex;
386
+ align-items: center;
387
+ justify-content: flex-start; /* Aligns to the left/start of the section */
388
+ gap: 12px;
389
+ /* Add margin/padding to separate it visually */
390
+ margin: 0.5rem 0;
391
+ }
392
+
393
+ /* NEW: Segmented Control Wrapper Style */
394
+ .voice-toggle-control {
395
+ display: flex;
396
+ align-items: center;
397
+ /* Pill shape for the entire control */
398
+ border-radius: 9999px;
399
+ /* Soft border to define the area */
400
+ border: 2px solid #009688; /* Use a color that stands out from the teacher-section bg */
401
+ background-color: #f0f8ff; /* Light background */
402
+ padding: 4px; /* Padding inside the control */
403
+ }
404
+
405
+ /* Label Group Styles */
406
+ .voice-label-group {
407
+ /* Make segments clickable and give them an initial look */
408
+ padding: 0.5rem 1rem;
409
+ font-size: 0.95rem; /* Slightly smaller for compactness */
410
+ color: #006780;
411
+ font-weight: 500;
412
+ cursor: pointer;
413
+ transition: all 0.3s ease;
414
+ white-space: nowrap;
415
+ user-select: none;
416
+ min-width: 6vw; /* Ensure minimum width for visibility */
417
+ text-align: center;
418
+ }
419
+
420
+ .voice-text {
421
+ /* Ensure the span font properties are appealing */
422
+ font-weight: 600; /* Bolder text for visibility */
423
+ font-size: 0.9vw; /* Adjusted to fit the new design */
424
+ }
425
+
426
+ /* Active Segment Style (Unique UI) */
427
+ .voice-label-group.active {
428
+ /* Highlight the active selection */
429
+ background-color: #006780; /* Strong, contrasting background */
430
+ color: white; /* White text on active background */
431
+ font-weight: 700;
432
+ box-shadow: 0 4px 10px rgba(0, 103, 128, 0.3); /* Subtle lift */
433
+ }
434
+
435
+ /* Specific border-radius for segments */
436
+ .toggle-label-left {
437
+ border-radius: 9999px 0 0 9999px; /* Rounded on the left */
438
+ }
439
+
440
+ .toggle-label-right {
441
+ border-radius: 0 9999px 9999px 0; /* Rounded on the right */
442
+ }
443
+
444
+ /* Angular Material Toggle Styles for Integration */
445
+ /* Target the slide toggle container */
446
+ .voice-toggle-slider {
447
+ /* Hide the toggle itself but keep its functionality space (optional) */
448
+ height: 24px;
449
+ width: 4px; /* Acts as a vertical separator */
450
+ background-color: transparent; /* Ensure no residual background */
451
+ opacity: 1; /* Keep visible for debugging if needed, but its children are hidden */
452
+ margin: 0 -2px; /* Maintain small gap/separator illusion */
453
+ position: relative;
454
+ overflow: hidden; /* Crucial to clip any remaining visual elements */
455
+ }
456
+
457
+ /* IMPORTANT: Aggressive Hiding for all internal Material elements */
458
+ .voice-toggle-slider .mdc-switch__track,
459
+ .voice-toggle-slider .mdc-switch__icons,
460
+ .voice-toggle-slider .mdc-switch__thumb-handle,
461
+ .voice-toggle-slider .mat-mdc-slide-toggle-ripple,
462
+ .voice-toggle-slider .mat-mdc-slide-toggle-focus-ring,
463
+ .voice-toggle-slider .mdc-switch__handle {
464
+ /* Set width/height to zero and hide completely */
465
+ width: 0 !important;
466
+ height: 0 !important;
467
+ padding: 0 !important;
468
+ margin: 0 !important;
469
+ opacity: 0 !important;
470
+ visibility: hidden !important; /* Critical to ensure hiding */
471
+ display: none !important; /* The most aggressive hide */
472
+ }
473
+
474
+ /* Optional: If you want a visual separator line, add it here */
475
+ .voice-toggle-slider::before {
476
+ content: '';
477
+ position: absolute;
478
+ top: 50%;
479
+ left: 50%;
480
+ transform: translate(-50%, -50%);
481
+ width: 2px;
482
+ height: 80%; /* Height of the separator */
483
+ background-color: rgba(0, 150, 136, 0.4); /* Divider color (lighter than the border) */
484
+ border-radius: 1px;
485
+ }
486
+
487
+ /* Adjust the original span's font-size to be slightly bigger since the toggle is now prominent */
488
+ span {
489
+ font-weight: bold;
490
+ font-size: 1vw; /* Increased from 0.8vw for better legibility */
491
+ }
492
+
493
+ /* Override existing label group for the new look */
494
+ .voice-label-group {
495
+ /* Resetting previous style from your original CSS for segments */
496
+ font-size: 1rem; /* Use a fixed size instead of 'vw' for consistency in labels */
497
+ color: #006780;
498
+ transition: all 0.3s;
499
+ font-weight: 500;
500
+ line-height: 1; /* Ensure text fits */
501
+ display: flex;
502
+ align-items: center;
503
+ justify-content: center;
504
+ }
505
+
506
+ .record-btn {
507
+ display: inline-flex;
508
+ align-items: center;
509
+ gap: 8px;
510
+ }
511
+
512
+ .record-btn.recording {
513
+ background-color: #e53935;
514
+ border-color: #d32f2f;
515
+ color: #ffffff;
516
+ box-shadow: 0 2px 6px rgba(229, 57, 53, 0.24);
517
+ }
518
+
519
+ .record-btn.recording .icon,
520
+ .record-btn.recording .stop-icon {
521
+ color: #fff;
522
+ fill: #fff;
523
+ stroke: none;
524
+ }
525
+
526
+ .stop-icon {
527
+ width: 18px;
528
+ height: 18px;
529
+ display: inline-block;
530
+ vertical-align: middle;
531
+ }
532
+
533
+ .user-guide-close-icon {
534
+ position: fixed;
535
+ top: 3vw;
536
+ right: 4vw;
537
+ background: #009688;
538
+ border: none;
539
+ width: 44px;
540
+ height: 44px;
541
+ border-radius: 50%;
542
+ display: flex;
543
+ align-items: center;
544
+ justify-content: center;
545
+ font-size: 2vw;
546
+ color: black;
547
+ cursor: pointer;
548
+ z-index: 2010;
549
+ box-shadow: 0 2px 8px rgba(93, 145, 195, 0.18);
550
+ transition: background 0.2s, color 0.2s;
551
+ }
552
+
553
+ /* Responsive */
554
+ @media (max-width: 768px) {
555
+ .title {
556
+ font-size: 1.75rem;
557
+ }
558
+
559
+ .word {
560
+ font-size: 2rem;
561
+ }
562
+
563
+ .phonetics {
564
+ font-size: 1.25rem;
565
+ }
566
+
567
+ .apple-image {
568
+ width: 12rem;
569
+ height: 12rem;
570
+ }
571
+
572
+ .btn {
573
+ padding: 0.625rem 1.25rem;
574
+ font-size: 0.875rem;
575
+ }
576
+ }
577
+
578
+
579
+ span {
580
+ font-weight: bold;
581
+ font-size:0.8vw;
582
+ }
src/app/pronunciation/pronunciation.component.html ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="pron-container">
2
+ <!-- Header -->
3
+ <div class="header">
4
+
5
+ <h2 class="title">Pronunciation Trainer</h2>
6
+ </div>
7
+
8
+ <!-- Main Content -->
9
+ <div class="main-content">
10
+ <!-- Left Side - Apple Image -->
11
+ <div class="image-section">
12
+ <img [src]="imgsrc" [alt]="word" class="apple-image">
13
+ </div>
14
+
15
+ <!-- Right Side - Word and Controls -->
16
+ <div class="controls-section">
17
+ <!-- Word with Phonetics -->
18
+ <div class="word-section">
19
+ <h2 class="word">{{ word }}</h2>
20
+ <p class="phonetics">{{ phonetics }}</p>
21
+ </div>
22
+
23
+ <!-- Teacher Audio Button -->
24
+ <div class="teacher-section">
25
+ <img src="assets/images/chat/natasha.png" alt="Teacher" class="avatar">
26
+ <button class="btn btn-primary"
27
+ id="playTeacherBtn"
28
+ (click)="playTeacherAudio()"
29
+ [disabled]="isTeacherLoading"
30
+ [attr.aria-busy]="isTeacherLoading">
31
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
32
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
33
+ </svg>
34
+ <span class="btn-text" *ngIf="!isTeacherLoading">Play Teacher</span>
35
+ <span class="btn-text" *ngIf="isTeacherLoading">Loading...</span>
36
+ <svg class="spinner" viewBox="0 0 24 24" [class.hidden]="!isTeacherLoading">
37
+ <circle class="spinner-circle" cx="12" cy="12" r="10"></circle>
38
+ </svg>
39
+ </button>
40
+
41
+ <div class="voice-selection-container">
42
+ <div class="voice-toggle-control">
43
+ <div class="voice-label-group toggle-label-left"
44
+ [class.active]="!isOriginal"
45
+ (click)="isOriginal = false; updateSelection()">
46
+ <span class="voice-text">Original Voice</span>
47
+ </div>
48
+
49
+ <div class="voice-label-group toggle-label-right"
50
+ [class.active]="isOriginal"
51
+ (click)="isOriginal = true; updateSelection()">
52
+ <span class="voice-text">Cloned Voice</span>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <!-- Student Recording Section -->
59
+ <div class="student-section">
60
+ <img src="assets/images/pron/student.png" alt="Student" class="avatar">
61
+ <div class="button-group">
62
+ <button class="btn btn-secondary record-btn"
63
+ id="recordBtn"
64
+ (click)="isRecording ? stopRecording() : startRecording()"
65
+ [class.recording]="isRecording"
66
+ [attr.aria-pressed]="isRecording">
67
+ <ng-container *ngIf="!isRecording; else stopTpl">
68
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
69
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
70
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
71
+ <line x1="12" x2="12" y1="19" y2="22"></line>
72
+ </svg>
73
+ <span class="btn-text">Start Recording</span>
74
+ </ng-container>
75
+
76
+ <ng-template #stopTpl>
77
+ <svg class="icon stop-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none" width="18" height="18" aria-hidden="true">
78
+ <rect x="6" y="6" width="12" height="12" rx="2"></rect>
79
+ </svg>
80
+ <span class="btn-text">Stop</span>
81
+ </ng-template>
82
+ </button>
83
+
84
+ <!-- Play Student Recording (shows when a recording exists) -->
85
+ <button class="btn btn-accent"
86
+ id="playStudentBtn"
87
+ *ngIf="recordedBlobUrl"
88
+ (click)="playRecorded()">
89
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
90
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
91
+ </svg>
92
+ <span class="btn-text">Play My Recording</span>
93
+ </button>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Check Button -->
98
+ <button class="btn btn-success full-width"
99
+ id="checkBtn"
100
+ (click)="checkPronunciation()"
101
+ [disabled]="isChecking || !recordedBlobUrl"
102
+ [attr.aria-busy]="isChecking"
103
+ [attr.aria-disabled]="isChecking || !recordedBlobUrl"
104
+ aria-label="Check pronunciation">
105
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" *ngIf="!isChecking">
106
+ <polyline points="20 6 9 17 4 12"></polyline>
107
+ </svg>
108
+
109
+ <span class="btn-text" *ngIf="!isChecking">Check Pronunciation</span>
110
+ <span class="btn-text" *ngIf="isChecking">Checking...</span>
111
+
112
+ <svg class="spinner" viewBox="0 0 24 24" *ngIf="isChecking">
113
+ <circle class="spinner-circle" cx="12" cy="12" r="10"></circle>
114
+ </svg>
115
+ </button>
116
+
117
+ </div>
118
+
119
+ <div class="results-section" id="resultsSection">
120
+
121
+ <!-- Score Display: always show speakometer when result exists -->
122
+ <div class="gauge-wrapper">
123
+ <div class="gauge">
124
+ <div class="gauge-arc"></div>
125
+ <!-- set CSS variable from Angular -->
126
+ <div class="needle" [style.--angle]="needleAngle + 'deg'" aria-hidden="true"></div>
127
+ </div>
128
+
129
+ <div class="mic-badge">
130
+ <span class="score-span" aria-live="polite">{{ result?.score ?? 0 }}%</span>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Suggestions (paged, 2 per page) -->
135
+ <div class="suggestions-section" *ngIf="result">
136
+ <h3 class="suggestions-title">Feedback & Suggestions</h3>
137
+
138
+ <!-- If structured suggestions exist, show current page (2 items) -->
139
+ <div class="sugg-div" *ngIf="suggestions.length > 0; else legacySingleString">
140
+ <div class="suggestions-page">
141
+ <div class="suggestion-card" *ngFor="let s of pagedSuggestions; let i = index">
142
+ <div class="suggestion-badge" aria-hidden="true">{{ s.id ?? (suggestionPage * suggestionsPerPage + i + 1) }}</div>
143
+ <div class="suggestion-body">
144
+ <div class="suggestion-point">{{ s.title }}</div>
145
+ <div class="suggestion-feedback" [innerHTML]="s.message"></div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Pagination controls -->
151
+ <div class="suggestion-nav">
152
+ <button class="btn btn-outline btn-nav pagebtn" (click)="prevSuggestion()" [disabled]="suggestionPage === 0" aria-label="Previous feedback">◀</button>
153
+
154
+ <div class="nav-info">
155
+ {{ suggestionPage + 1 }} / {{ totalSuggestionPages }}
156
+ </div>
157
+
158
+ <button class="btn btn-outline btn-nav pagebtn" (click)="nextSuggestion()" [disabled]="suggestionPage >= totalSuggestionPages - 1" aria-label="Next feedback">▶</button>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Fallback when suggestion was provided as a single string -->
163
+ <ng-template #legacySingleString>
164
+ <div class="suggestion-item" *ngIf="result.suggestion">
165
+ <div class="suggestion-content">
166
+ <p class="suggestion-feedback">{{ result.suggestion }}</p>
167
+ </div>
168
+ </div>
169
+ </ng-template>
170
+ </div>
171
+
172
+ <div>
173
+ <div class="nav-buttons">
174
+ <button class="btn btn-outline btn-nav" (click)="prevQuestion()" [disabled]="currentIndex === 0" aria-label="Previous">
175
+ ◀ Prev
176
+ </button>
177
+
178
+ <div class="nav-info">
179
+ {{ currentIndex + 1 }} / {{ questions.length }}
180
+ </div>
181
+
182
+ <button class="btn btn-outline btn-nav" (click)="nextQuestion()" [disabled]="currentIndex === questions.length - 1" aria-label="Next">
183
+ Next ▶
184
+ </button>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <button aria-label="Close" class="user-guide-close-icon" (click)="closePopup()">×</button>
190
+ <!-- Add a hidden audio element for programmatic playback -->
191
+
192
+ </div>
193
+ </div>
src/app/pronunciation/pronunciation.component.ts ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // (trimmed waveform/canvas logic; kept recording/playback, API and pagination)
2
+ import { Component, OnDestroy, ChangeDetectorRef, Inject, OnInit } from '@angular/core';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
5
+ import { ApiService } from './pronunciation.service'; // adjust path if needed
6
+
7
+ @Component({
8
+ selector: 'app-pronunciation',
9
+ templateUrl: './pronunciation.component.html',
10
+ styleUrls: ['./pronunciation.component.css']
11
+ })
12
+ export class PronunciationComponent implements OnDestroy, OnInit {
13
+ // Word UI
14
+ word = 'Apple';
15
+ phonetics = '/ˈæpəl/';
16
+ imgsrc = 'assets/images/pron/letter-a.png';
17
+
18
+ // navigation state
19
+ questions: Array<{ word: string; imgsrc: string; phonetics?: string }> = [
20
+ { word: 'Apple', imgsrc: 'assets/images/pron/letter-a.png', phonetics: '/ˈæpəl/' },
21
+ { word: 'Ball', imgsrc: 'assets/images/pron/letter-b.png', phonetics: '/bɔːl/' },
22
+ { word: 'Cat', imgsrc: 'assets/images/pron/letter-c.png', phonetics: '/kæt/' },
23
+ { word: 'Dog', imgsrc: 'assets/images/pron/letter-d.png', phonetics: '/dɒɡ/' },
24
+ { word: 'Egg', imgsrc: 'assets/images/pron/letter-e.png', phonetics: '/eɡ/' },
25
+ { word: 'Fish', imgsrc: 'assets/images/pron/letter-f.png', phonetics: '/fɪʃ/' },
26
+ { word: 'Grapes', imgsrc: 'assets/images/pron/letter-g.png', phonetics: '/ɡreɪps/' },
27
+ { word: 'Hat', imgsrc: 'assets/images/pron/letter-h.png', phonetics: '/hæt/' },
28
+ { word: 'Ice cream', imgsrc: 'assets/images/pron/letter-i.png', phonetics: '/ˈaɪs ˌkriːm/' },
29
+ { word: 'Jar', imgsrc: 'assets/images/pron/letter-j.png', phonetics: '/dʒɑːr/' },
30
+ { word: 'Kite', imgsrc: 'assets/images/pron/letter-k.png', phonetics: '/kaɪt/' },
31
+ { word: 'Lion', imgsrc: 'assets/images/pron/letter-l.png', phonetics: '/ˈlaɪən/' },
32
+ { word: 'Moon', imgsrc: 'assets/images/pron/letter-m.png', phonetics: '/muːn/' },
33
+ { word: 'Nest', imgsrc: 'assets/images/pron/letter-n.png', phonetics: '/nest/' },
34
+ { word: 'Orange', imgsrc: 'assets/images/pron/letter-o.png', phonetics: '/ˈɒrɪndʒ/' },
35
+ { word: 'Pig', imgsrc: 'assets/images/pron/letter-p.png', phonetics: '/pɪɡ/' },
36
+ { word: 'Queen', imgsrc: 'assets/images/pron/letter-q.png', phonetics: '/kwiːn/' },
37
+ { word: 'Rabbit', imgsrc: 'assets/images/pron/letter-r.png', phonetics: '/ˈræbɪt/' },
38
+ { word: 'Sun', imgsrc: 'assets/images/pron/letter-s.png', phonetics: '/sʌn/' },
39
+ { word: 'Tree', imgsrc: 'assets/images/pron/letter-t.png', phonetics: '/triː/' },
40
+ { word: 'Umbrella', imgsrc: 'assets/images/pron/letter-u.png', phonetics: '/ʌmˈbrelə/' },
41
+ { word: 'Van', imgsrc: 'assets/images/pron/letter-v.png', phonetics: '/væn/' },
42
+ { word: 'Watch', imgsrc: 'assets/images/pron/letter-w.png', phonetics: '/wɒtʃ/' },
43
+ { word: 'Xylophone', imgsrc: 'assets/images/pron/letter-x.png', phonetics: '/ˈzaɪləfəʊn/' },
44
+ { word: 'Yarn', imgsrc: 'assets/images/pron/letter-y.png', phonetics: '/jɑːn/' },
45
+ { word: 'Zebra', imgsrc: 'assets/images/pron/letter-z.png', phonetics: '/ˈzebrə/' }
46
+ ];
47
+
48
+ currentIndex = 0;
49
+
50
+ // Backend (match ApiService host logic so you don't get host mismatches)
51
+ backendURL = location.hostname.endsWith('hf.space')
52
+ ? 'https://pykara-py-learn-backend.hf.space'
53
+ : 'http://localhost:5000';
54
+
55
+ // Teacher playback
56
+ teacherAudio?: HTMLAudioElement;
57
+
58
+ // Cache teacher audio URLs per word to avoid repeated API calls
59
+ private teacherAudioCache = new Map<string, string>();
60
+
61
+ // Loading state for teacher audio request
62
+ isTeacherLoading = false;
63
+
64
+ // Recording
65
+ isRecording = false;
66
+ private mediaRecorder?: MediaRecorder;
67
+ private recordedBlobs: Blob[] = [];
68
+ recordedBlobUrl?: string;
69
+ private recordedAudio?: HTMLAudioElement;
70
+
71
+ // Result
72
+ result: { score?: number; suggestion?: string; feedbackAudioUrl?: string; phonemeScore?: number; acousticScore?: number } | null = null;
73
+
74
+ // Structured suggestions array (one card per item from backend)
75
+ suggestions: Array<{ id?: number; title?: string; type?: string; message?: string }> = [];
76
+
77
+ // Pagination for suggestions (page index)
78
+ public suggestionPage = 0;
79
+ public suggestionsPerPage = 1;
80
+
81
+ // Media Recording Variables
82
+ private audioChunks: Blob[] = [];
83
+ audioBlobUrl: string | null = null;
84
+
85
+ // Timer Variables
86
+ recordingDuration: number = 0;
87
+ private timer: any;
88
+ isOriginal: boolean = false;
89
+ private micStream?: MediaStream;
90
+
91
+ // Helper promise/resolver so callers can await the mediaRecorder.onstop completion
92
+ private recordingStopPromise?: Promise<void>;
93
+ private recordingStopResolver?: (() => void) | null = null;
94
+
95
+ constructor(private http: HttpClient, private cdr: ChangeDetectorRef,
96
+ public dialogRef: MatDialogRef<PronunciationComponent>,
97
+ @Inject(MAT_DIALOG_DATA) public data: any,
98
+ private api: ApiService) {
99
+ // minimal constructor; init in ngOnInit
100
+ }
101
+
102
+ ngOnInit(): void {
103
+ this.showQuestion(0);
104
+ }
105
+
106
+ // Called when the toggle changes
107
+ updateSelection() {
108
+ // stop any current teacher playback and clear cache so selection change takes immediate effect
109
+ this.stopTeacherPlayback();
110
+ this.teacherAudioCache.clear();
111
+ this.cdr.detectChanges();
112
+ }
113
+
114
+ // Helper: build static asset path for a word (normalize to lowercase, underscores)
115
+ private getStaticTeacherAudioPath(word: string): string {
116
+ const clean = (word || '')
117
+ .toLowerCase()
118
+ .trim()
119
+ .replace(/\s+/g, '_') // spaces -> underscores
120
+ .replace(/[^a-z0-9_]/g, ''); // remove other chars
121
+ return `assets/audio/original/${clean}.m4a`;
122
+ }
123
+
124
+ // Play teacher audio: use local static file when original voice selected, otherwise fall back to existing flow.
125
+ async playTeacherAudio(): Promise<void> {
126
+ // If original voice selected, prefer a local static asset
127
+ if (!this.isOriginal) {
128
+ const staticPath = this.getStaticTeacherAudioPath(this.word);
129
+ this.isTeacherLoading = true;
130
+ try {
131
+ const resp = await fetch(staticPath, { method: 'HEAD' });
132
+ if (resp.ok) {
133
+ this.teacherAudioCache.set(this.word, staticPath);
134
+ this.playAudioWithWaveform(staticPath, 'teacher');
135
+ this.isTeacherLoading = false;
136
+ return;
137
+ }
138
+ } catch (err) {
139
+ console.warn('Static teacher audio check failed, will use generated audio', err);
140
+ }
141
+ this.isTeacherLoading = false;
142
+ }
143
+
144
+ const cached = this.teacherAudioCache.get(this.word);
145
+ if (cached) {
146
+ this.isTeacherLoading = true;
147
+ this.playAudioWithWaveform(cached, 'teacher');
148
+ this.isTeacherLoading = false;
149
+ return;
150
+ }
151
+
152
+ this.isTeacherLoading = true;
153
+
154
+ // Try streaming audio bytes from the backend (no server-side file). Fallback to existing blob/url flows.
155
+ this.api.generateTeacherAudioStream(this.word, this.selectedFile).subscribe({
156
+ next: (blob) => {
157
+ try {
158
+ const objUrl = URL.createObjectURL(blob);
159
+ const prev = this.teacherAudioCache.get(this.word);
160
+ if (prev && prev.startsWith('blob:')) {
161
+ try { URL.revokeObjectURL(prev); } catch { }
162
+ }
163
+ this.teacherAudioCache.set(this.word, objUrl);
164
+ this.playAudioWithWaveform(objUrl, 'teacher');
165
+ } finally {
166
+ this.isTeacherLoading = false;
167
+ }
168
+ },
169
+ error: (err) => {
170
+ console.warn('generateTeacherAudioStream failed, falling back to generateTeacherAudioBlob/url', err);
171
+ // fallback to existing behavior (request URL then fetch)
172
+ this.api.generateTeacherAudioBlob(this.word, this.selectedFile).subscribe({
173
+ next: ({ audioUrl, blob }) => {
174
+ const objUrl = URL.createObjectURL(blob);
175
+ const prev = this.teacherAudioCache.get(this.word);
176
+ if (prev && prev.startsWith('blob:')) {
177
+ try { URL.revokeObjectURL(prev); } catch { }
178
+ }
179
+ this.teacherAudioCache.set(this.word, objUrl);
180
+ this.playAudioWithWaveform(objUrl, 'teacher');
181
+ this.isTeacherLoading = false;
182
+ },
183
+ error: (e) => {
184
+ console.error('generateTeacherAudioBlob fallback failed', e);
185
+ // as a last resort, try URL-only endpoint
186
+ this.api.generateTeacherAudio(this.word, this.selectedFile).subscribe({
187
+ next: ({ audioUrl }) => {
188
+ this.teacherAudioCache.set(this.word, audioUrl);
189
+ this.playAudioWithWaveform(audioUrl, 'teacher');
190
+ this.isTeacherLoading = false;
191
+ },
192
+ error: (ee) => {
193
+ console.error('generateTeacherAudio fallback failed', ee);
194
+ this.isTeacherLoading = false;
195
+ }
196
+ });
197
+ }
198
+ });
199
+ }
200
+ });
201
+ }
202
+
203
+ // Simplified playback: no waveform/canvas support in finalized HTML
204
+ public playAudioWithWaveform(src: string, type: 'teacher' | 'recorded'): void {
205
+ if (!src) return;
206
+
207
+ if (type === 'teacher') {
208
+ this.stopTeacherPlayback();
209
+ this.teacherAudio = new Audio();
210
+ try {
211
+ if (!src.startsWith('blob:')) {
212
+ this.teacherAudio.crossOrigin = 'anonymous';
213
+ }
214
+ } catch { /* ignore */ }
215
+ this.teacherAudio.preload = 'auto';
216
+ this.teacherAudio.src = src;
217
+ this.teacherAudio.oncanplay = () => {
218
+ this.teacherAudio!.play().catch(() => { /* ignore */ });
219
+ };
220
+ this.teacherAudio.onended = () => { /* no-op */ };
221
+ try { this.teacherAudio.load(); } catch { /* ignore */ }
222
+ } else {
223
+ // recorded playback
224
+ this.stopRecordedPlayback();
225
+ this.recordedAudio = new Audio();
226
+ try {
227
+ if (!src.startsWith('blob:')) {
228
+ this.recordedAudio.crossOrigin = 'anonymous';
229
+ }
230
+ } catch { /* ignore */ }
231
+ this.recordedAudio.preload = 'auto';
232
+ this.recordedAudio.src = src;
233
+ this.recordedAudio.oncanplay = () => {
234
+ this.recordedAudio!.play().catch(() => { /* ignore */ });
235
+ };
236
+ this.recordedAudio.onended = () => this.stopRecordedPlayback();
237
+ try { this.recordedAudio.load(); } catch { /* ignore */ }
238
+ }
239
+ }
240
+
241
+ private stopTeacherPlayback(): void {
242
+ if (this.teacherAudio) {
243
+ try { this.teacherAudio.pause(); this.teacherAudio.onended = null; } catch { }
244
+ this.teacherAudio = undefined;
245
+ }
246
+ }
247
+
248
+ private stopRecordedPlayback(): void {
249
+ if (this.recordedAudio) {
250
+ try { this.recordedAudio.pause(); this.recordedAudio.onended = null; } catch { }
251
+ this.recordedAudio = undefined;
252
+ }
253
+ }
254
+
255
+ // Loading state for check pronunciation request
256
+ isChecking = false;
257
+
258
+ // Updated checkPronunciation: ensure recording has stopped, build a reliable Blob and fall back to object URL if needed.
259
+ async checkPronunciation(): Promise<void> {
260
+ // If currently recording, stop and wait for onstop handler to finish.
261
+ if (this.isRecording && this.mediaRecorder) {
262
+ try {
263
+ this.mediaRecorder.stop();
264
+ if (this.recordingStopPromise) {
265
+ await this.recordingStopPromise;
266
+ }
267
+ } catch (e) {
268
+ console.warn('Error stopping recorder before check:', e);
269
+ }
270
+ }
271
+
272
+ // If there is an object URL for the finalized recording, prefer fetching it.
273
+ let audioBlob: Blob | null = null;
274
+
275
+ if (this.recordedBlobs && this.recordedBlobs.length > 0) {
276
+ // merge recorded chunk blobs
277
+ const inferredType = this.recordedBlobs[0]?.type || 'audio/webm';
278
+ audioBlob = new Blob(this.recordedBlobs, { type: inferredType });
279
+ } else if (this.audioBlobUrl) {
280
+ // fetch blob from the object URL created in onstop
281
+ try {
282
+ const resp = await fetch(this.audioBlobUrl);
283
+ audioBlob = await resp.blob();
284
+ } catch (e) {
285
+ console.warn('Failed to fetch audioBlob from audioBlobUrl:', e);
286
+ }
287
+ }
288
+
289
+ if (!audioBlob || audioBlob.size === 0) {
290
+ // nothing recorded or still not available
291
+ this.result = { score: 0, suggestion: 'No audio recorded. Please record and try again.' };
292
+ this.suggestions = [{ id: 1, title: 'No Audio', message: 'No audio was found. Record again and ensure microphone permissions are allowed.' }];
293
+ this.suggestionPage = 0;
294
+ this.cdr.detectChanges();
295
+ return;
296
+ }
297
+
298
+ this.isChecking = true;
299
+ this.cdr.detectChanges();
300
+
301
+ // Choose a filename extension that matches the blob type if possible
302
+ let ext = '.webm';
303
+ try {
304
+ const t = audioBlob.type || '';
305
+ if (t.includes('wav')) ext = '.wav';
306
+ else if (t.includes('ogg')) ext = '.ogg';
307
+ else if (t.includes('webm')) ext = '.webm';
308
+ else if (t.includes('mpeg') || t.includes('mp3')) ext = '.mp3';
309
+ } catch { /* ignore */ }
310
+
311
+ const filename = `${this.word}${ext}`;
312
+ const form = new FormData();
313
+ form.append('audio', audioBlob, filename);
314
+ form.append('word', this.word);
315
+
316
+ try {
317
+ const res = await this.http.post<any>(`${this.backendURL}/check_pronunciation`, form).toPromise();
318
+
319
+ // Compute phonemic score (backend provides phoneme_similarity in range 0..1)
320
+ const phonemeSimilarity = Number(res?.phoneme_similarity ?? res?.phonemeSimilarity ?? 0);
321
+ const phonemePct = Math.round(Math.max(0, Math.min(1, phonemeSimilarity)) * 100);
322
+
323
+ // Keep acoustic score if you want to display/use later
324
+ const acousticScore = Number(res?.acoustic_score ?? res?.score ?? res?.score_acoustic ?? 0);
325
+
326
+ // Set the speakometer to show phonemic score scaled to 0..100
327
+ this.result = {
328
+ score: phonemePct,
329
+ suggestion: (typeof res?.suggestion === 'string') ? res.suggestion : '',
330
+ feedbackAudioUrl: res?.audio_url ? `${this.backendURL}/${res.audio_url}` : undefined,
331
+ phonemeScore: phonemePct,
332
+ acousticScore: isNaN(acousticScore) ? undefined : acousticScore
333
+ };
334
+
335
+ // Build structured suggestions with sanitized messages and derived titles
336
+ if (Array.isArray(res?.suggestion)) {
337
+ this.suggestions = res.suggestion.map((s: any, idx: number) => {
338
+ const raw = (typeof s === 'string') ? s : (s.message ?? JSON.stringify(s));
339
+ const msg = this.sanitizeFeedbackText(raw);
340
+ const explicitTitle = (s && s.title) ? this.sanitizeFeedbackText(String(s.title)) : '';
341
+ const title = explicitTitle || this.deriveTitleFromMessage(msg, idx);
342
+ return {
343
+ id: s?.id ?? (idx + 1),
344
+ title,
345
+ type: s?.type ?? '',
346
+ message: msg
347
+ };
348
+ });
349
+ } else if (typeof res?.suggestion === 'string' && res.suggestion.trim()) {
350
+ const msg = this.sanitizeFeedbackText(res.suggestion);
351
+ this.suggestions = [{ id: 1, title: this.deriveTitleFromMessage(msg, 0), type: '', message: msg }];
352
+ } else {
353
+ this.suggestions = [];
354
+ }
355
+
356
+ // Reset suggestion pagination
357
+ this.suggestionPage = 0;
358
+
359
+ } catch (err) {
360
+ console.error('check_pronunciation error', err);
361
+ this.result = { score: 0, suggestion: 'Server error' };
362
+ this.suggestions = [{ id: 1, title: 'Error', message: 'Server error' }];
363
+ this.suggestionPage = 0;
364
+ } finally {
365
+ this.isChecking = false;
366
+ this.cdr.detectChanges();
367
+ }
368
+ }
369
+
370
+ // Remove leading non-alphanumeric markers (tick marks, bullets) and trim
371
+ private sanitizeFeedbackText(text: string): string {
372
+ if (!text) return '';
373
+ // strip leading non-alphanumeric characters and extra whitespace
374
+ const cleaned = text.replace(/^[^A-Za-z0-9]+/, '').trim();
375
+ return cleaned;
376
+ }
377
+
378
+ // Derive a short human-friendly title from message text using keyword rules
379
+ private deriveTitleFromMessage(msg: string, idx: number): string {
380
+ if (!msg) return `Feedback ${idx + 1}`;
381
+
382
+ const m = msg.toLowerCase();
383
+
384
+ if (m.includes('vowel')) return 'Vowel';
385
+ if (m.includes('consonant')) return 'Consonant';
386
+ if (m.includes('missing') || m.includes('missing sounds') || m.includes('missing sound')) return 'Missing Sounds';
387
+ if (m.includes('filler') || m.startsWith('uh ') || m.startsWith('ah ')) return 'Extra Sounds';
388
+ if (m.includes('syllable')) return 'Syllables';
389
+ if (m.includes('stress')) return 'Stress';
390
+ if (m.includes('timing') || m.includes('speed') || m.includes('pace')) return 'Timing & Pace';
391
+ if (m.includes('quiet') || m.includes('soft') || m.includes('loud') || m.includes('volume')) return 'Volume';
392
+ if (m.includes('whisper') || m.includes('system heard') || m.includes('recognized') || m.includes('heard')) return 'Word Match';
393
+ if (m.includes('natural')) return 'Naturalness';
394
+ if (m.includes('clarity') || m.includes('noise') || m.includes('unclear')) return 'Clarity';
395
+ if (m.includes('audio') && m.includes('noise')) return 'Audio Quality';
396
+
397
+ // Fallback: first 4 words, trimmed and capitalized
398
+ const words = msg.split(/\s+/).slice(0, 4).map(w => w.replace(/[^a-zA-Z0-9'-]/g, ''));
399
+ let title = words.join(' ');
400
+ if (!title) title = `Feedback ${idx + 1}`;
401
+ // Capitalize first letter of each word for tidy display
402
+ title = title.split(' ').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
403
+ return title;
404
+ }
405
+
406
+ ngOnDestroy(): void {
407
+ this.stopTeacherPlayback();
408
+ this.stopRecordedPlayback();
409
+ if (this.micStream) {
410
+ try { this.micStream.getTracks().forEach(t => t.stop()); } catch { }
411
+ this.micStream = undefined;
412
+ }
413
+ }
414
+
415
+ selectedFile?: File;
416
+
417
+ onFileSelected(ev: Event) {
418
+ const input = ev.target as HTMLInputElement;
419
+ if (input.files && input.files.length) {
420
+ this.selectedFile = input.files[0];
421
+ if (this.teacherAudioCache.has(this.word)) {
422
+ this.teacherAudioCache.delete(this.word);
423
+ }
424
+ }
425
+ }
426
+
427
+ requestTeacherAudio() {
428
+ const cached = this.teacherAudioCache.get(this.word);
429
+ if (cached) {
430
+ const a = new Audio(cached);
431
+ a.play().catch(err => console.warn('play failed', err));
432
+ return;
433
+ }
434
+
435
+ this.api.generateTeacherAudio(this.word, this.selectedFile).subscribe({
436
+ next: ({ audioUrl }) => {
437
+ this.teacherAudioCache.set(this.word, audioUrl);
438
+ const a = new Audio(audioUrl);
439
+ a.play().catch(err => console.warn('play failed', err));
440
+ },
441
+ error: err => {
442
+ console.error('generateTeacherAudio failed', err);
443
+ }
444
+ });
445
+ }
446
+
447
+ async startRecording() {
448
+ // Reset previous state
449
+ this.audioBlobUrl = null;
450
+ this.audioChunks = [];
451
+ this.recordedBlobs = [];
452
+ this.isRecording = true;
453
+ this.recordingDuration = 0;
454
+
455
+ // Prepare promise so callers can await onstop
456
+ this.recordingStopPromise = new Promise<void>((resolve) => {
457
+ this.recordingStopResolver = resolve;
458
+ });
459
+
460
+ // Start timer
461
+ this.timer = setInterval(() => {
462
+ this.recordingDuration++;
463
+ this.cdr.detectChanges();
464
+ }, 1000);
465
+
466
+ try {
467
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
468
+ this.micStream = stream;
469
+ this.mediaRecorder = new MediaRecorder(stream);
470
+
471
+ this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
472
+ if (event.data && event.data.size > 0) {
473
+ this.audioChunks.push(event.data);
474
+ this.recordedBlobs.push(event.data);
475
+ }
476
+ };
477
+
478
+ this.mediaRecorder.onstop = async () => {
479
+ clearInterval(this.timer);
480
+
481
+ const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm; codecs=opus' });
482
+
483
+ if (this.audioBlobUrl) {
484
+ try { URL.revokeObjectURL(this.audioBlobUrl); } catch { }
485
+ }
486
+ this.audioBlobUrl = URL.createObjectURL(audioBlob);
487
+ this.recordedBlobUrl = this.audioBlobUrl;
488
+
489
+ try { if (this.micStream) this.micStream.getTracks().forEach((t: any) => t.stop()); } catch { }
490
+ this.micStream = undefined;
491
+
492
+ this.isRecording = false;
493
+ this.cdr.detectChanges();
494
+
495
+ // resolve any waiter
496
+ if (this.recordingStopResolver) {
497
+ try { this.recordingStopResolver(); } catch { }
498
+ this.recordingStopResolver = null;
499
+ this.recordingStopPromise = undefined;
500
+ }
501
+ };
502
+
503
+ // Start actual recording
504
+ this.mediaRecorder.start();
505
+ } catch (err) {
506
+ this.isRecording = false;
507
+ clearInterval(this.timer);
508
+ console.error('Error accessing microphone:', err);
509
+ alert('Could not access microphone. Ensure permissions are granted.');
510
+ // resolve promise to avoid deadlocks if start failed
511
+ if (this.recordingStopResolver) {
512
+ try { this.recordingStopResolver(); } catch { }
513
+ this.recordingStopResolver = null;
514
+ this.recordingStopPromise = undefined;
515
+ }
516
+ }
517
+ }
518
+
519
+ stopRecording() {
520
+ if (this.mediaRecorder && this.isRecording) {
521
+ this.mediaRecorder.stop();
522
+ this.isRecording = false;
523
+ } else {
524
+ clearInterval(this.timer);
525
+ this.isRecording = false;
526
+ }
527
+ }
528
+
529
+ // Public playback for recorded audio
530
+ public playRecorded(): void {
531
+ if (!this.recordedBlobUrl) return;
532
+ this.playAudioWithWaveform(this.recordedBlobUrl, 'recorded');
533
+ }
534
+
535
+ public get needleAngle(): number {
536
+ const score = Math.max(0, Math.min(100, Number(this.result?.score ?? 0)));
537
+ return -90 + (score / 100) * 180;
538
+ }
539
+
540
+ public get suggestionCount(): number {
541
+ return this.suggestions?.length ?? 0;
542
+ }
543
+
544
+ public get totalSuggestionPages(): number {
545
+ return Math.max(1, Math.ceil(this.suggestionCount / this.suggestionsPerPage));
546
+ }
547
+
548
+ public get pagedSuggestions(): Array<{ id?: number; title?: string; type?: string; message?: string }> {
549
+ if (!this.suggestions || this.suggestions.length === 0) return [];
550
+ const start = this.suggestionPage * this.suggestionsPerPage;
551
+ return this.suggestions.slice(start, start + this.suggestionsPerPage);
552
+ }
553
+
554
+ public prevSuggestion(): void {
555
+ if (this.suggestionPage > 0) {
556
+ this.suggestionPage--;
557
+ }
558
+ }
559
+
560
+ public nextSuggestion(): void {
561
+ if (this.suggestionPage < this.totalSuggestionPages - 1) {
562
+ this.suggestionPage++;
563
+ }
564
+ }
565
+
566
+ public showSuggestionAt(pageIndex: number): void {
567
+ if (pageIndex >= 0 && pageIndex < this.totalSuggestionPages) {
568
+ this.suggestionPage = pageIndex;
569
+ }
570
+ }
571
+
572
+ closePopup(): void {
573
+ this.dialogRef.close();
574
+ }
575
+
576
+ public prevQuestion(): void {
577
+ if (this.currentIndex > 0) {
578
+ this.showQuestion(this.currentIndex - 1);
579
+ }
580
+ }
581
+
582
+ public nextQuestion(): void {
583
+ if (this.currentIndex < this.questions.length - 1) {
584
+ this.showQuestion(this.currentIndex + 1);
585
+ }
586
+ }
587
+
588
+ public showQuestion(index: number): void {
589
+ if (index < 0 || index >= this.questions.length) return;
590
+
591
+ this.stopTeacherPlayback();
592
+ this.stopRecordedPlayback();
593
+
594
+ if (this.audioBlobUrl && this.audioBlobUrl.startsWith('blob:')) {
595
+ try { URL.revokeObjectURL(this.audioBlobUrl); } catch { /* ignore */ }
596
+ }
597
+ this.audioBlobUrl = null;
598
+ this.recordedBlobUrl = undefined;
599
+ this.recordedBlobs = [];
600
+ this.audioChunks = [];
601
+
602
+ this.result = null;
603
+ this.suggestions = [];
604
+ this.suggestionPage = 0;
605
+
606
+ this.currentIndex = index;
607
+ const q = this.questions[index];
608
+ this.word = q.word;
609
+ this.phonetics = q.phonetics ?? '';
610
+ this.imgsrc = q.imgsrc ?? this.imgsrc;
611
+
612
+ this.cdr.detectChanges();
613
+ }
614
+ }
src/app/pronunciation/pronunciation.service.ts ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@angular/core';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { Observable, throwError } from 'rxjs';
4
+ import { tap, catchError, map, switchMap } from 'rxjs/operators';
5
+
6
+ @Injectable({
7
+ providedIn: 'root'
8
+ })
9
+ export class ApiService {
10
+
11
+ // Use single rag host (adjust port to match your backend)
12
+ private ragHost = location.hostname.endsWith('hf.space')
13
+ ? 'https://pykara-py-learn-backend.hf.space'
14
+ : 'http://localhost:5000'; // <-- ensure this matches your Flask app port
15
+
16
+ // Pronunciation blueprint base path (matches backend blueprint url_prefix '/pron')
17
+ private pronBase = `${this.ragHost}/`;
18
+
19
+ constructor(private http: HttpClient) { }
20
+
21
+
22
+ generateTeacherAudio(word: string, reference?: File): Observable<{ audioUrl: string }> {
23
+ const url = `${this.pronBase}/generate_teacher_audio`;
24
+
25
+ if (reference) {
26
+ const form = new FormData();
27
+ form.append('word', word);
28
+ form.append('reference', reference, reference.name);
29
+
30
+ // Do NOT set Content-Type header for FormData; browser sets the boundary.
31
+ return this.http.post<any>(url, form).pipe(
32
+ map(res => ({ audioUrl: `${this.pronBase}/${res.audio_url}` })),
33
+ tap(r => console.log('[API] generateTeacherAudio (with ref) ->', r)),
34
+ catchError(err => {
35
+ console.error('[API] generateTeacherAudio error', err);
36
+ return throwError(() => err);
37
+ })
38
+ );
39
+ } else {
40
+ const headers = { 'Content-Type': 'application/json' };
41
+ return this.http.post<any>(url, { word }, { headers }).pipe(
42
+ map(res => ({ audioUrl: `${this.pronBase}/${res.audio_url}` })),
43
+ tap(r => console.log('[API] generateTeacherAudio ->', r)),
44
+ catchError(err => {
45
+ console.error('[API] generateTeacherAudio error', err);
46
+ return throwError(() => err);
47
+ })
48
+ );
49
+ }
50
+ }
51
+
52
+ // New: request teacher audio as raw bytes (no server-side persistent file)
53
+ // Expects a backend endpoint that returns audio bytes (wav) directly.
54
+ generateTeacherAudioStream(word: string, reference?: File): Observable<Blob> {
55
+ const streamUrl = `${this.pronBase}/generate_teacher_audio_stream`;
56
+
57
+ if (reference) {
58
+ const form = new FormData();
59
+ form.append('word', word);
60
+ form.append('reference', reference, reference.name);
61
+ // post form and expect blob response
62
+ return this.http.post(streamUrl, form, { responseType: 'blob' }).pipe(
63
+ tap(() => console.log('[API] generateTeacherAudioStream (with ref)')),
64
+ map((b: any) => b as Blob),
65
+ catchError(err => {
66
+ console.error('[API] generateTeacherAudioStream error', err);
67
+ return throwError(() => err);
68
+ })
69
+ );
70
+ } else {
71
+ const headers = { 'Content-Type': 'application/json' };
72
+ return this.http.post(streamUrl, { word }, { headers, responseType: 'blob' as 'json' }).pipe(
73
+ tap(() => console.log('[API] generateTeacherAudioStream')),
74
+ map((b: any) => b as Blob),
75
+ catchError(err => {
76
+ console.error('[API] generateTeacherAudioStream error', err);
77
+ return throwError(() => err);
78
+ })
79
+ );
80
+ }
81
+ }
82
+
83
+ // Helper: generate teacher audio then download it as a Blob
84
+ // Returns { audioUrl, blob } where audioUrl is the full URL and blob is the audio bytes.
85
+ generateTeacherAudioBlob(word: string, reference?: File): Observable<{ audioUrl: string; blob: Blob }> {
86
+ const postUrl = `${this.pronBase}/generate_teacher_audio`;
87
+
88
+ const post$ = reference
89
+ ? (() => {
90
+ const form = new FormData();
91
+ form.append('word', word);
92
+ form.append('reference', reference, reference.name);
93
+ return this.http.post<any>(postUrl, form);
94
+ })()
95
+ : (() => {
96
+ const headers = { 'Content-Type': 'application/json' };
97
+ return this.http.post<any>(postUrl, { word }, { headers });
98
+ })();
99
+
100
+ return post$.pipe(
101
+ switchMap((res: any) => {
102
+ // backend returns e.g. "audio/teacher-xxxx.wav" — build full URL under /pron
103
+ const audioUrl = `${this.pronBase}/${res.audio_url}`;
104
+ // fetch blob
105
+ return this.http.get(audioUrl, { responseType: 'blob' }).pipe(
106
+ map((b: Blob) => ({ audioUrl, blob: b })),
107
+ tap(() => console.log('[API] downloaded teacher audio as blob', audioUrl))
108
+ );
109
+ }),
110
+ catchError(err => {
111
+ console.error('[API] generateTeacherAudioBlob error', err);
112
+ return throwError(() => err);
113
+ })
114
+ );
115
+ }
116
+ }