Oviya
commited on
Commit
·
0b92c10
1
Parent(s):
3f95782
update pron component
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- package.json +1 -0
- src/app/pronunciation/pronunciation.component.css +66 -3
- src/app/pronunciation/pronunciation.component.html +39 -16
- src/app/pronunciation/pronunciation.component.ts +274 -17
- src/app/pronunciation/pronunciation.service.ts +30 -58
- src/assets/audio/apple.mp3 +3 -0
- src/assets/audio/ball.mp3 +3 -0
- src/assets/audio/cat.mp3 +3 -0
- src/assets/audio/dog.mp3 +3 -0
- src/assets/audio/egg.mp3 +3 -0
- src/assets/audio/fish.mp3 +3 -0
- src/assets/audio/grapes.mp3 +3 -0
- src/assets/audio/hat.mp3 +3 -0
- src/assets/audio/ice_cream.mp3 +3 -0
- src/assets/audio/jar.mp3 +3 -0
- src/assets/audio/kite.mp3 +3 -0
- src/assets/audio/lion.mp3 +3 -0
- src/assets/audio/moon.mp3 +3 -0
- src/assets/audio/nest.mp3 +3 -0
- src/assets/audio/orange.mp3 +3 -0
- src/assets/audio/original/ball.m4a +0 -0
- src/assets/audio/original/cat.m4a +0 -0
- src/assets/audio/original/dog.m4a +0 -0
- src/assets/audio/original/egg.m4a +0 -0
- src/assets/audio/original/fish.m4a +0 -0
- src/assets/audio/original/grapes.m4a +0 -0
- src/assets/audio/original/hat.m4a +0 -0
- src/assets/audio/original/ice_cream.m4a +0 -0
- src/assets/audio/original/jar.m4a +0 -0
- src/assets/audio/original/kite.m4a +0 -0
- src/assets/audio/original/lion.m4a +0 -0
- src/assets/audio/original/moon.m4a +0 -0
- src/assets/audio/original/nest.m4a +0 -0
- src/assets/audio/original/orange.m4a +0 -0
- src/assets/audio/original/pig.m4a +0 -0
- src/assets/audio/original/queen.m4a +0 -0
- src/assets/audio/original/rabbit.m4a +0 -0
- src/assets/audio/original/sun.m4a +0 -0
- src/assets/audio/original/tree.m4a +0 -0
- src/assets/audio/original/van.m4a +0 -0
- src/assets/audio/original/watch.m4a +0 -0
- src/assets/audio/original/xylophone.m4a +0 -0
- src/assets/audio/original/yarn.m4a +0 -0
- src/assets/audio/original/zebra.m4a +0 -0
- src/assets/audio/pig.mp3 +3 -0
- src/assets/audio/queen.mp3 +3 -0
- src/assets/audio/rabbit.mp3 +3 -0
- src/assets/audio/sun.mp3 +3 -0
- src/assets/audio/tree.mp3 +3 -0
- src/assets/audio/umbrella.mp3 +3 -0
package.json
CHANGED
|
@@ -30,6 +30,7 @@
|
|
| 30 |
"rxjs": "~7.8.0",
|
| 31 |
"tailwindcss": "^3.4.17",
|
| 32 |
"tslib": "^2.3.0",
|
|
|
|
| 33 |
"zone.js": "~0.14.3"
|
| 34 |
},
|
| 35 |
"devDependencies": {
|
|
|
|
| 30 |
"rxjs": "~7.8.0",
|
| 31 |
"tailwindcss": "^3.4.17",
|
| 32 |
"tslib": "^2.3.0",
|
| 33 |
+
"wavesurfer.js": "^7.11.1",
|
| 34 |
"zone.js": "~0.14.3"
|
| 35 |
},
|
| 36 |
"devDependencies": {
|
src/app/pronunciation/pronunciation.component.css
CHANGED
|
@@ -8,8 +8,71 @@
|
|
| 8 |
flex-direction: column;
|
| 9 |
}
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
.header {
|
| 12 |
-
text-align: center
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
.title {
|
|
@@ -53,8 +116,8 @@
|
|
| 53 |
.controls-section {
|
| 54 |
display: flex;
|
| 55 |
flex-direction: column;
|
| 56 |
-
justify-content:
|
| 57 |
-
gap:
|
| 58 |
width: 30vw;
|
| 59 |
}
|
| 60 |
|
|
|
|
| 8 |
flex-direction: column;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
/* Keep the dropdown at the start */
|
| 12 |
+
.dropdown-row {
|
| 13 |
+
display: flex;
|
| 14 |
+
align-items: center;
|
| 15 |
+
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* Accessible helper — hide visually but keep available to screen readers */
|
| 19 |
+
.visually-hidden {
|
| 20 |
+
position: absolute !important;
|
| 21 |
+
height: 1px;
|
| 22 |
+
width: 1px;
|
| 23 |
+
overflow: hidden;
|
| 24 |
+
clip: rect(1px, 1px, 1px, 1px);
|
| 25 |
+
white-space: nowrap;
|
| 26 |
+
border: 0;
|
| 27 |
+
padding: 0;
|
| 28 |
+
margin: -1px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Styled select that fits the app button styles */
|
| 32 |
+
.select-dropdown {
|
| 33 |
+
appearance: none;
|
| 34 |
+
-webkit-appearance: none;
|
| 35 |
+
-moz-appearance: none;
|
| 36 |
+
display: inline-block;
|
| 37 |
+
min-width: 180px;
|
| 38 |
+
height: 36px;
|
| 39 |
+
padding: 6px 36px 6px 12px; /* extra right padding for custom arrow */
|
| 40 |
+
font-size: 14px;
|
| 41 |
+
line-height: 1;
|
| 42 |
+
color: #111827;
|
| 43 |
+
background-color: #ffffff;
|
| 44 |
+
border: 1px solid #d1d5db;
|
| 45 |
+
border-radius: 8px;
|
| 46 |
+
background-image: linear-gradient(45deg, transparent 50%, #6b7280 50%), linear-gradient(135deg, #6b7280 50%, transparent 50%), linear-gradient(to right, #fff, #fff);
|
| 47 |
+
background-position: calc(100% - 18px) calc(1em + 2px), calc(100% - 13px) calc(1em + 2px), 100% 0;
|
| 48 |
+
background-size: 6px 6px, 6px 6px, 2.5em 2.5em;
|
| 49 |
+
background-repeat: no-repeat;
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* Hover / focus states for accessibility */
|
| 54 |
+
.select-dropdown:hover {
|
| 55 |
+
border-color: #9ca3af;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.select-dropdown:focus {
|
| 59 |
+
outline: none;
|
| 60 |
+
box-shadow: 0 0 0 3px rgba(59,130,246,0.14);
|
| 61 |
+
border-color: #3b82f6;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Smaller variant if needed */
|
| 65 |
+
.select-dropdown.small {
|
| 66 |
+
min-width: 140px;
|
| 67 |
+
height: 32px;
|
| 68 |
+
font-size: 13px;
|
| 69 |
+
border-radius: 6px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
.header {
|
| 73 |
+
/*text-align: center;*/
|
| 74 |
+
display:flex;
|
| 75 |
+
gap:13vw;
|
| 76 |
}
|
| 77 |
|
| 78 |
.title {
|
|
|
|
| 116 |
.controls-section {
|
| 117 |
display: flex;
|
| 118 |
flex-direction: column;
|
| 119 |
+
justify-content: space-evenly;
|
| 120 |
+
gap: 0.5vw;
|
| 121 |
width: 30vw;
|
| 122 |
}
|
| 123 |
|
src/app/pronunciation/pronunciation.component.html
CHANGED
|
@@ -1,7 +1,16 @@
|
|
| 1 |
<div class="pron-container">
|
| 2 |
<!-- Header -->
|
| 3 |
<div class="header">
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
<h2 class="title">Pronunciation Trainer</h2>
|
| 6 |
</div>
|
| 7 |
|
|
@@ -44,7 +53,7 @@
|
|
| 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"
|
|
@@ -94,6 +103,19 @@
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<!-- Check Button -->
|
| 98 |
<button class="btn btn-success full-width"
|
| 99 |
id="checkBtn"
|
|
@@ -118,7 +140,7 @@
|
|
| 118 |
|
| 119 |
<div class="results-section" id="resultsSection">
|
| 120 |
|
| 121 |
-
<!-- Score Display: always
|
| 122 |
<div class="gauge-wrapper">
|
| 123 |
<div class="gauge">
|
| 124 |
<div class="gauge-arc"></div>
|
|
@@ -131,12 +153,12 @@
|
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
|
| 134 |
-
<!-- Suggestions
|
| 135 |
-
<div class="suggestions-section" *ngIf="
|
| 136 |
<h3 class="suggestions-title">Feedback & Suggestions</h3>
|
| 137 |
|
| 138 |
-
<!--
|
| 139 |
-
<div class="sugg-div"
|
| 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>
|
|
@@ -158,15 +180,16 @@
|
|
| 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 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
</
|
| 168 |
</div>
|
| 169 |
-
</
|
| 170 |
</div>
|
| 171 |
|
| 172 |
<div>
|
|
@@ -188,6 +211,6 @@
|
|
| 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>
|
|
|
|
|
|
| 1 |
<div class="pron-container">
|
| 2 |
<!-- Header -->
|
| 3 |
<div class="header">
|
| 4 |
+
<div class="dropdown-row" aria-hidden="false">
|
| 5 |
+
<label for="pronModeSelect" class="visually-hidden">Pronunciation Mode</label>
|
| 6 |
+
<select id="pronModeSelect" class="select-dropdown" aria-label="Pronunciation Mode" (change)="onModeChange($any($event.target).value)">
|
| 7 |
+
<option value="phonetics">Phoneme-Level Accuracy Check</option>
|
| 8 |
+
<option value="waveform">Audio Similarity Check</option>
|
| 9 |
+
<option value="normaltext" disabled>Speech-to-Text Accuracy Check</option>
|
| 10 |
+
<option value="prosody" disabled>Prosody Check</option>
|
| 11 |
+
<option value="embedding" disabled>Embedding Similarity Check</option>
|
| 12 |
+
</select>
|
| 13 |
+
</div>
|
| 14 |
<h2 class="title">Pronunciation Trainer</h2>
|
| 15 |
</div>
|
| 16 |
|
|
|
|
| 53 |
[class.active]="!isOriginal"
|
| 54 |
(click)="isOriginal = false; updateSelection()">
|
| 55 |
<span class="voice-text">Original Voice</span>
|
| 56 |
+
</div>
|
| 57 |
|
| 58 |
<div class="voice-label-group toggle-label-right"
|
| 59 |
[class.active]="isOriginal"
|
|
|
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
|
| 106 |
+
<!-- Waveform containers (shown only in Audio Similarity Check mode) -->
|
| 107 |
+
<div *ngIf="pronMode === 'waveform'" class="waveform-row">
|
| 108 |
+
<div class="waveform-block">
|
| 109 |
+
<div class="waveform-title">Teacher</div>
|
| 110 |
+
<div id="teacherWaveform" class="waveform-canvas"></div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="waveform-block">
|
| 114 |
+
<div class="waveform-title">Student</div>
|
| 115 |
+
<div id="studentWaveform" class="waveform-canvas"></div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
<!-- Check Button -->
|
| 120 |
<button class="btn btn-success full-width"
|
| 121 |
id="checkBtn"
|
|
|
|
| 140 |
|
| 141 |
<div class="results-section" id="resultsSection">
|
| 142 |
|
| 143 |
+
<!-- Score Display: always shown (initially 0) so speakometer is visible from start -->
|
| 144 |
<div class="gauge-wrapper">
|
| 145 |
<div class="gauge">
|
| 146 |
<div class="gauge-arc"></div>
|
|
|
|
| 153 |
</div>
|
| 154 |
</div>
|
| 155 |
|
| 156 |
+
<!-- Suggestions: show whenever we have suggestions, regardless of result -->
|
| 157 |
+
<div class="suggestions-section" *ngIf="suggestions.length > 0">
|
| 158 |
<h3 class="suggestions-title">Feedback & Suggestions</h3>
|
| 159 |
|
| 160 |
+
<!-- Structured suggestions -->
|
| 161 |
+
<div class="sugg-div">
|
| 162 |
<div class="suggestions-page">
|
| 163 |
<div class="suggestion-card" *ngFor="let s of pagedSuggestions; let i = index">
|
| 164 |
<div class="suggestion-badge" aria-hidden="true">{{ s.id ?? (suggestionPage * suggestionsPerPage + i + 1) }}</div>
|
|
|
|
| 180 |
<button class="btn btn-outline btn-nav pagebtn" (click)="nextSuggestion()" [disabled]="suggestionPage >= totalSuggestionPages - 1" aria-label="Next feedback">▶</button>
|
| 181 |
</div>
|
| 182 |
</div>
|
| 183 |
+
</div>
|
| 184 |
|
| 185 |
+
<!-- Legacy single-string suggestion: hide in select mode and only show when not using structured suggestions -->
|
| 186 |
+
<div class="suggestions-section" *ngIf="pronMode !== 'select' && (!suggestions || suggestions.length === 0) && result?.suggestion">
|
| 187 |
+
<h3 class="suggestions-title">Feedback & Suggestions</h3>
|
| 188 |
+
<div class="suggestion-item">
|
| 189 |
+
<div class="suggestion-content">
|
| 190 |
+
<p class="suggestion-feedback">{{ result?.suggestion }}</p>
|
| 191 |
</div>
|
| 192 |
+
</div>
|
| 193 |
</div>
|
| 194 |
|
| 195 |
<div>
|
|
|
|
| 211 |
|
| 212 |
<button aria-label="Close" class="user-guide-close-icon" (click)="closePopup()">×</button>
|
| 213 |
<!-- Add a hidden audio element for programmatic playback -->
|
| 214 |
+
|
|
|
|
| 215 |
</div>
|
| 216 |
+
</div>
|
src/app/pronunciation/pronunciation.component.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 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',
|
|
@@ -92,6 +92,11 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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,
|
|
@@ -103,14 +108,69 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 103 |
this.showQuestion(0);
|
| 104 |
}
|
| 105 |
|
| 106 |
-
//
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 || '')
|
|
@@ -118,7 +178,7 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 118 |
.trim()
|
| 119 |
.replace(/\s+/g, '_') // spaces -> underscores
|
| 120 |
.replace(/[^a-z0-9_]/g, ''); // remove other chars
|
| 121 |
-
return `assets/audio
|
| 122 |
}
|
| 123 |
|
| 124 |
// Play teacher audio: use local static file when original voice selected, otherwise fall back to existing flow.
|
|
@@ -200,10 +260,53 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 200 |
});
|
| 201 |
}
|
| 202 |
|
| 203 |
-
// Simplified playback:
|
| 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();
|
|
@@ -238,11 +341,32 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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 {
|
|
@@ -250,6 +374,9 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 250 |
try { this.recordedAudio.pause(); this.recordedAudio.onended = null; } catch { }
|
| 251 |
this.recordedAudio = undefined;
|
| 252 |
}
|
|
|
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
// Loading state for check pronunciation request
|
|
@@ -269,15 +396,12 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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();
|
|
@@ -313,17 +437,129 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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}/pron/check_pronunciation`, form).toPromise();
|
| 318 |
|
| 319 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 : '',
|
|
@@ -332,7 +568,7 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 332 |
acousticScore: isNaN(acousticScore) ? undefined : acousticScore
|
| 333 |
};
|
| 334 |
|
| 335 |
-
//
|
| 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));
|
|
@@ -352,8 +588,6 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 352 |
} else {
|
| 353 |
this.suggestions = [];
|
| 354 |
}
|
| 355 |
-
|
| 356 |
-
// Reset suggestion pagination
|
| 357 |
this.suggestionPage = 0;
|
| 358 |
|
| 359 |
} catch (err) {
|
|
@@ -406,6 +640,7 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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;
|
|
@@ -427,6 +662,11 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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;
|
|
@@ -435,8 +675,12 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 435 |
this.api.generateTeacherAudio(this.word, this.selectedFile).subscribe({
|
| 436 |
next: ({ audioUrl }) => {
|
| 437 |
this.teacherAudioCache.set(this.word, audioUrl);
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
},
|
| 441 |
error: err => {
|
| 442 |
console.error('generateTeacherAudio failed', err);
|
|
@@ -486,6 +730,11 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 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 |
|
|
@@ -609,6 +858,14 @@ export class PronunciationComponent implements OnDestroy, OnInit {
|
|
| 609 |
this.phonetics = q.phonetics ?? '';
|
| 610 |
this.imgsrc = q.imgsrc ?? this.imgsrc;
|
| 611 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
this.cdr.detectChanges();
|
| 613 |
}
|
| 614 |
}
|
|
|
|
|
|
|
| 1 |
import { Component, OnDestroy, ChangeDetectorRef, Inject, OnInit } from '@angular/core';
|
| 2 |
import { HttpClient } from '@angular/common/http';
|
| 3 |
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
| 4 |
import { ApiService } from './pronunciation.service'; // adjust path if needed
|
| 5 |
+
import WaveSurfer from 'wavesurfer.js';
|
| 6 |
|
| 7 |
@Component({
|
| 8 |
selector: 'app-pronunciation',
|
|
|
|
| 92 |
private recordingStopPromise?: Promise<void>;
|
| 93 |
private recordingStopResolver?: (() => void) | null = null;
|
| 94 |
|
| 95 |
+
// WaveSurfer state
|
| 96 |
+
pronMode: string = 'phonetics'; // current dropdown mode
|
| 97 |
+
private teacherWaveSurfer: any;
|
| 98 |
+
private studentWaveSurfer: any;
|
| 99 |
+
|
| 100 |
constructor(private http: HttpClient, private cdr: ChangeDetectorRef,
|
| 101 |
public dialogRef: MatDialogRef<PronunciationComponent>,
|
| 102 |
@Inject(MAT_DIALOG_DATA) public data: any,
|
|
|
|
| 108 |
this.showQuestion(0);
|
| 109 |
}
|
| 110 |
|
| 111 |
+
// handle dropdown mode change
|
| 112 |
+
public onModeChange(mode: string | null | undefined) {
|
| 113 |
+
const m = (mode ?? 'phonetics').toString().trim().toLowerCase();
|
| 114 |
+
console.log('[Pronunciation] onModeChange ->', { raw: mode, normalized: m });
|
| 115 |
+
this.pronMode = m;
|
| 116 |
+
if (m === 'waveform') {
|
| 117 |
+
this.initWaveSurfers();
|
| 118 |
+
const cached = this.teacherAudioCache.get(this.word);
|
| 119 |
+
if (cached && this.teacherWaveSurfer) {
|
| 120 |
+
try { this.teacherWaveSurfer.load(cached); } catch { /* ignore */ }
|
| 121 |
+
}
|
| 122 |
+
if (this.recordedBlobUrl && this.studentWaveSurfer) {
|
| 123 |
+
try { this.studentWaveSurfer.load(this.recordedBlobUrl); } catch { /* ignore */ }
|
| 124 |
+
}
|
| 125 |
+
} else {
|
| 126 |
+
this.destroyWaveSurfers();
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Added: updateSelection used by template toggle
|
| 131 |
+
public updateSelection(): void {
|
| 132 |
// stop any current teacher playback and clear cache so selection change takes immediate effect
|
| 133 |
this.stopTeacherPlayback();
|
| 134 |
this.teacherAudioCache.clear();
|
| 135 |
this.cdr.detectChanges();
|
| 136 |
}
|
| 137 |
|
| 138 |
+
private initWaveSurfers(): void {
|
| 139 |
+
// avoid re-creating
|
| 140 |
+
if (!this.teacherWaveSurfer) {
|
| 141 |
+
try {
|
| 142 |
+
this.teacherWaveSurfer = WaveSurfer.create({
|
| 143 |
+
container: '#teacherWaveform',
|
| 144 |
+
waveColor: '#d0e9ff',
|
| 145 |
+
progressColor: '#007acc',
|
| 146 |
+
cursorColor: '#333',
|
| 147 |
+
height: 80
|
| 148 |
+
|
| 149 |
+
});
|
| 150 |
+
} catch (err) { console.warn('could not create teacher WaveSurfer', err); }
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (!this.studentWaveSurfer) {
|
| 154 |
+
try {
|
| 155 |
+
this.studentWaveSurfer = WaveSurfer.create({
|
| 156 |
+
container: '#studentWaveform',
|
| 157 |
+
waveColor: '#ffdcd0',
|
| 158 |
+
progressColor: '#ff6b3d',
|
| 159 |
+
cursorColor: '#333',
|
| 160 |
+
height: 80
|
| 161 |
+
|
| 162 |
+
});
|
| 163 |
+
} catch (err) { console.warn('could not create student WaveSurfer', err); }
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
private destroyWaveSurfers(): void {
|
| 168 |
+
try {
|
| 169 |
+
if (this.teacherWaveSurfer) { try { this.teacherWaveSurfer.destroy(); } catch { } this.teacherWaveSurfer = undefined; }
|
| 170 |
+
if (this.studentWaveSurfer) { try { this.studentWaveSurfer.destroy(); } catch { } this.studentWaveSurfer = undefined; }
|
| 171 |
+
} catch { /* ignore */ }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
// Helper: build static asset path for a word (normalize to lowercase, underscores)
|
| 175 |
private getStaticTeacherAudioPath(word: string): string {
|
| 176 |
const clean = (word || '')
|
|
|
|
| 178 |
.trim()
|
| 179 |
.replace(/\s+/g, '_') // spaces -> underscores
|
| 180 |
.replace(/[^a-z0-9_]/g, ''); // remove other chars
|
| 181 |
+
return `assets/audio/${clean}.mp3`;
|
| 182 |
}
|
| 183 |
|
| 184 |
// Play teacher audio: use local static file when original voice selected, otherwise fall back to existing flow.
|
|
|
|
| 260 |
});
|
| 261 |
}
|
| 262 |
|
| 263 |
+
// Simplified playback: now supports WaveSurfer mode
|
| 264 |
public playAudioWithWaveform(src: string, type: 'teacher' | 'recorded'): void {
|
| 265 |
if (!src) return;
|
| 266 |
|
| 267 |
+
// If the dropdown is in waveform mode, use WaveSurfer instances
|
| 268 |
+
if (this.pronMode === 'waveform') {
|
| 269 |
+
// ensure wavesurfer instances exist
|
| 270 |
+
this.initWaveSurfers();
|
| 271 |
+
|
| 272 |
+
if (type === 'teacher') {
|
| 273 |
+
try {
|
| 274 |
+
if (this.teacherWaveSurfer) {
|
| 275 |
+
// load will accept blob/object URL or remote URL
|
| 276 |
+
this.teacherWaveSurfer.load(src);
|
| 277 |
+
// autoplay when ready
|
| 278 |
+
this.teacherWaveSurfer.once('ready', () => {
|
| 279 |
+
try { this.teacherWaveSurfer.play(); } catch { /* ignore */ }
|
| 280 |
+
});
|
| 281 |
+
} else {
|
| 282 |
+
// fallback to simple audio element
|
| 283 |
+
this.fallbackAudioPlay(src, 'teacher');
|
| 284 |
+
}
|
| 285 |
+
} catch (e) {
|
| 286 |
+
console.warn('WaveSurfer teacher play failed, fallback', e);
|
| 287 |
+
this.fallbackAudioPlay(src, 'teacher');
|
| 288 |
+
}
|
| 289 |
+
} else {
|
| 290 |
+
// recorded
|
| 291 |
+
try {
|
| 292 |
+
if (this.studentWaveSurfer) {
|
| 293 |
+
this.studentWaveSurfer.load(src);
|
| 294 |
+
this.studentWaveSurfer.once('ready', () => {
|
| 295 |
+
try { this.studentWaveSurfer.play(); } catch { /* ignore */ }
|
| 296 |
+
});
|
| 297 |
+
} else {
|
| 298 |
+
this.fallbackAudioPlay(src, 'recorded');
|
| 299 |
+
}
|
| 300 |
+
} catch (e) {
|
| 301 |
+
console.warn('WaveSurfer student play failed, fallback', e);
|
| 302 |
+
this.fallbackAudioPlay(src, 'recorded');
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Non-waveform fallback (existing behavior)
|
| 310 |
if (type === 'teacher') {
|
| 311 |
this.stopTeacherPlayback();
|
| 312 |
this.teacherAudio = new Audio();
|
|
|
|
| 341 |
}
|
| 342 |
}
|
| 343 |
|
| 344 |
+
// Simple fallback audio creation used when wavesurfer errors
|
| 345 |
+
private fallbackAudioPlay(src: string, type: 'teacher' | 'recorded') {
|
| 346 |
+
if (type === 'teacher') {
|
| 347 |
+
this.stopTeacherPlayback();
|
| 348 |
+
const a = new Audio(src);
|
| 349 |
+
try { if (!src.startsWith('blob:')) a.crossOrigin = 'anonymous'; } catch { }
|
| 350 |
+
a.play().catch(() => { /* ignore */ });
|
| 351 |
+
this.teacherAudio = a;
|
| 352 |
+
} else {
|
| 353 |
+
this.stopRecordedPlayback();
|
| 354 |
+
const a = new Audio(src);
|
| 355 |
+
try { if (!src.startsWith('blob:')) a.crossOrigin = 'anonymous'; } catch { }
|
| 356 |
+
a.play().catch(() => { /* ignore */ });
|
| 357 |
+
this.recordedAudio = a;
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
private stopTeacherPlayback(): void {
|
| 362 |
if (this.teacherAudio) {
|
| 363 |
try { this.teacherAudio.pause(); this.teacherAudio.onended = null; } catch { }
|
| 364 |
this.teacherAudio = undefined;
|
| 365 |
}
|
| 366 |
+
// also pause wavesurfer if present
|
| 367 |
+
if (this.teacherWaveSurfer) {
|
| 368 |
+
try { this.teacherWaveSurfer.pause(); } catch { }
|
| 369 |
+
}
|
| 370 |
}
|
| 371 |
|
| 372 |
private stopRecordedPlayback(): void {
|
|
|
|
| 374 |
try { this.recordedAudio.pause(); this.recordedAudio.onended = null; } catch { }
|
| 375 |
this.recordedAudio = undefined;
|
| 376 |
}
|
| 377 |
+
if (this.studentWaveSurfer) {
|
| 378 |
+
try { this.studentWaveSurfer.pause(); } catch { }
|
| 379 |
+
}
|
| 380 |
}
|
| 381 |
|
| 382 |
// Loading state for check pronunciation request
|
|
|
|
| 396 |
}
|
| 397 |
}
|
| 398 |
|
|
|
|
| 399 |
let audioBlob: Blob | null = null;
|
| 400 |
|
| 401 |
if (this.recordedBlobs && this.recordedBlobs.length > 0) {
|
|
|
|
| 402 |
const inferredType = this.recordedBlobs[0]?.type || 'audio/webm';
|
| 403 |
audioBlob = new Blob(this.recordedBlobs, { type: inferredType });
|
| 404 |
} else if (this.audioBlobUrl) {
|
|
|
|
| 405 |
try {
|
| 406 |
const resp = await fetch(this.audioBlobUrl);
|
| 407 |
audioBlob = await resp.blob();
|
|
|
|
| 437 |
form.append('audio', audioBlob, filename);
|
| 438 |
form.append('word', this.word);
|
| 439 |
|
| 440 |
+
// ensure mode normalized and appended
|
| 441 |
+
const modeToSend = (this.pronMode ?? 'phonetics').toString().trim().toLowerCase();
|
| 442 |
+
form.append('mode', modeToSend);
|
| 443 |
+
|
| 444 |
+
// optional: include reference file
|
| 445 |
+
if (this.selectedFile) {
|
| 446 |
+
form.append('reference', this.selectedFile);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// Debug: log mode and FormData entries (inspect network panel to confirm)
|
| 450 |
+
console.log('[Pronunciation] sending check_pronunciation', { word: this.word, mode: modeToSend });
|
| 451 |
+
try {
|
| 452 |
+
// enumerate FormData entries for verification
|
| 453 |
+
for (const [k, v] of (form as any).entries()) {
|
| 454 |
+
// for files, log name and size
|
| 455 |
+
if (v instanceof File) {
|
| 456 |
+
console.log(' FormData entry:', k, 'File name=', v.name, 'size=', v.size);
|
| 457 |
+
} else {
|
| 458 |
+
console.log(' FormData entry:', k, v);
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
} catch (e) {
|
| 462 |
+
console.warn('Could not enumerate FormData entries for logging', e);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
try {
|
| 466 |
const res = await this.http.post<any>(`${this.backendURL}/pron/check_pronunciation`, form).toPromise();
|
| 467 |
|
| 468 |
+
// Waveform mode response handling
|
| 469 |
+
if (res?.mode === 'waveform' || typeof res?.waveform_similarity !== 'undefined') {
|
| 470 |
+
const sim = Number(res.waveform_similarity ?? 0);
|
| 471 |
+
const matched = Boolean(res.waveform_match);
|
| 472 |
+
|
| 473 |
+
// Put similarity on your speakometer / needle
|
| 474 |
+
this.result = {
|
| 475 |
+
score: Math.round(sim),
|
| 476 |
+
suggestion: matched ? 'Audio matches teacher' : 'Audio does not match teacher',
|
| 477 |
+
feedbackAudioUrl: undefined,
|
| 478 |
+
phonemeScore: sim,
|
| 479 |
+
acousticScore: undefined
|
| 480 |
+
};
|
| 481 |
+
|
| 482 |
+
// Show simple structured feedback
|
| 483 |
+
this.suggestions = [{
|
| 484 |
+
id: 1,
|
| 485 |
+
title: 'Waveform Match',
|
| 486 |
+
message: `Similarity: ${sim}% — Matched: ${matched ? 'Yes' : 'No'}`
|
| 487 |
+
}];
|
| 488 |
+
|
| 489 |
+
// If backend returned teacher audio path, load it into WaveSurfer; otherwise request teacher audio and load
|
| 490 |
+
if (this.pronMode === 'waveform') {
|
| 491 |
+
// load student into wavesurfer
|
| 492 |
+
if (this.recordedBlobUrl && this.studentWaveSurfer) {
|
| 493 |
+
try { this.studentWaveSurfer.load(this.recordedBlobUrl); } catch { /* ignore */ }
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
// prefer backend-provided teacher url if present
|
| 497 |
+
if (res.teacher_audio_url) {
|
| 498 |
+
const teacherUrl = res.teacher_audio_url.startsWith('http') ? res.teacher_audio_url : `${this.backendURL}/${res.teacher_audio_url}`;
|
| 499 |
+
this.teacherAudioCache.set(this.word, teacherUrl);
|
| 500 |
+
if (this.teacherWaveSurfer) {
|
| 501 |
+
try { this.teacherWaveSurfer.load(teacherUrl); } catch { /* ignore */ }
|
| 502 |
+
}
|
| 503 |
+
} else {
|
| 504 |
+
// otherwise try cached or request generation
|
| 505 |
+
const cached = this.teacherAudioCache.get(this.word);
|
| 506 |
+
if (cached && this.teacherWaveSurfer) {
|
| 507 |
+
try { this.teacherWaveSurfer.load(cached); } catch { /* ignore */ }
|
| 508 |
+
} else {
|
| 509 |
+
// call API to get teacher URL (generate_teacher_audio returns url)
|
| 510 |
+
this.api.generateTeacherAudio(this.word, this.selectedFile).subscribe({
|
| 511 |
+
next: ({ audioUrl }) => {
|
| 512 |
+
this.teacherAudioCache.set(this.word, audioUrl);
|
| 513 |
+
if (this.teacherWaveSurfer) {
|
| 514 |
+
try { this.teacherWaveSurfer.load(audioUrl); } catch { /* ignore */ }
|
| 515 |
+
}
|
| 516 |
+
},
|
| 517 |
+
error: () => { /* ignore */ }
|
| 518 |
+
});
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
this.suggestionPage = 0;
|
| 524 |
+
this.isChecking = false;
|
| 525 |
+
this.cdr.detectChanges();
|
| 526 |
+
return;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// If frontend is in "select" mode do NOT bind score or suggestion — only populate suggestions returned by backend.
|
| 530 |
+
const isSelectMode = modeToSend === 'select' || this.pronMode === 'select';
|
| 531 |
+
if (isSelectMode) {
|
| 532 |
+
if (Array.isArray(res?.suggestion)) {
|
| 533 |
+
this.suggestions = res.suggestion.map((s: any, idx: number) => {
|
| 534 |
+
const raw = (typeof s === 'string') ? s : (s.message ?? JSON.stringify(s));
|
| 535 |
+
const msg = this.sanitizeFeedbackText(raw);
|
| 536 |
+
const explicitTitle = (s && s.title) ? this.sanitizeFeedbackText(String(s.title)) : '';
|
| 537 |
+
const title = explicitTitle || this.deriveTitleFromMessage(msg, idx);
|
| 538 |
+
return {
|
| 539 |
+
id: s?.id ?? (idx + 1),
|
| 540 |
+
title,
|
| 541 |
+
type: s?.type ?? '',
|
| 542 |
+
message: msg
|
| 543 |
+
};
|
| 544 |
+
});
|
| 545 |
+
} else if (typeof res?.suggestion === 'string' && res.suggestion.trim()) {
|
| 546 |
+
const msg = this.sanitizeFeedbackText(res.suggestion);
|
| 547 |
+
this.suggestions = [{ id: 1, title: this.deriveTitleFromMessage(msg, 0), type: '', message: msg }];
|
| 548 |
+
} else {
|
| 549 |
+
this.suggestions = [];
|
| 550 |
+
}
|
| 551 |
+
// Do not set this.result.score or this.result.suggestion for select mode
|
| 552 |
+
this.suggestionPage = 0;
|
| 553 |
+
this.isChecking = false;
|
| 554 |
+
this.cdr.detectChanges();
|
| 555 |
+
return;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
// --- existing phoneme/ASR handling (unchanged) ---
|
| 559 |
const phonemeSimilarity = Number(res?.phoneme_similarity ?? res?.phonemeSimilarity ?? 0);
|
| 560 |
const phonemePct = Math.round(Math.max(0, Math.min(1, phonemeSimilarity)) * 100);
|
|
|
|
|
|
|
| 561 |
const acousticScore = Number(res?.acoustic_score ?? res?.score ?? res?.score_acoustic ?? 0);
|
| 562 |
|
|
|
|
| 563 |
this.result = {
|
| 564 |
score: phonemePct,
|
| 565 |
suggestion: (typeof res?.suggestion === 'string') ? res.suggestion : '',
|
|
|
|
| 568 |
acousticScore: isNaN(acousticScore) ? undefined : acousticScore
|
| 569 |
};
|
| 570 |
|
| 571 |
+
// existing suggestion building...
|
| 572 |
if (Array.isArray(res?.suggestion)) {
|
| 573 |
this.suggestions = res.suggestion.map((s: any, idx: number) => {
|
| 574 |
const raw = (typeof s === 'string') ? s : (s.message ?? JSON.stringify(s));
|
|
|
|
| 588 |
} else {
|
| 589 |
this.suggestions = [];
|
| 590 |
}
|
|
|
|
|
|
|
| 591 |
this.suggestionPage = 0;
|
| 592 |
|
| 593 |
} catch (err) {
|
|
|
|
| 640 |
ngOnDestroy(): void {
|
| 641 |
this.stopTeacherPlayback();
|
| 642 |
this.stopRecordedPlayback();
|
| 643 |
+
this.destroyWaveSurfers();
|
| 644 |
if (this.micStream) {
|
| 645 |
try { this.micStream.getTracks().forEach(t => t.stop()); } catch { }
|
| 646 |
this.micStream = undefined;
|
|
|
|
| 662 |
requestTeacherAudio() {
|
| 663 |
const cached = this.teacherAudioCache.get(this.word);
|
| 664 |
if (cached) {
|
| 665 |
+
// if in waveform mode, load into wavesurfer instead of creating new audio
|
| 666 |
+
if (this.pronMode === 'waveform' && this.teacherWaveSurfer) {
|
| 667 |
+
try { this.teacherWaveSurfer.load(cached); this.teacherWaveSurfer.once('ready', () => this.teacherWaveSurfer.play()); } catch { /* ignore */ }
|
| 668 |
+
return;
|
| 669 |
+
}
|
| 670 |
const a = new Audio(cached);
|
| 671 |
a.play().catch(err => console.warn('play failed', err));
|
| 672 |
return;
|
|
|
|
| 675 |
this.api.generateTeacherAudio(this.word, this.selectedFile).subscribe({
|
| 676 |
next: ({ audioUrl }) => {
|
| 677 |
this.teacherAudioCache.set(this.word, audioUrl);
|
| 678 |
+
if (this.pronMode === 'waveform' && this.teacherWaveSurfer) {
|
| 679 |
+
try { this.teacherWaveSurfer.load(audioUrl); this.teacherWaveSurfer.once('ready', () => this.teacherWaveSurfer.play()); } catch { /* ignore */ }
|
| 680 |
+
} else {
|
| 681 |
+
const a = new Audio(audioUrl);
|
| 682 |
+
a.play().catch(err => console.warn('play failed', err));
|
| 683 |
+
}
|
| 684 |
},
|
| 685 |
error: err => {
|
| 686 |
console.error('generateTeacherAudio failed', err);
|
|
|
|
| 730 |
this.audioBlobUrl = URL.createObjectURL(audioBlob);
|
| 731 |
this.recordedBlobUrl = this.audioBlobUrl;
|
| 732 |
|
| 733 |
+
// If in waveform mode, load the student waveform for immediate playback/visualization
|
| 734 |
+
if (this.pronMode === 'waveform' && this.studentWaveSurfer) {
|
| 735 |
+
try { this.studentWaveSurfer.load(this.recordedBlobUrl); } catch { /* ignore */ }
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
try { if (this.micStream) this.micStream.getTracks().forEach((t: any) => t.stop()); } catch { }
|
| 739 |
this.micStream = undefined;
|
| 740 |
|
|
|
|
| 858 |
this.phonetics = q.phonetics ?? '';
|
| 859 |
this.imgsrc = q.imgsrc ?? this.imgsrc;
|
| 860 |
|
| 861 |
+
// If in waveform mode and teacher audio cached, load it for the new word
|
| 862 |
+
if (this.pronMode === 'waveform' && this.teacherWaveSurfer) {
|
| 863 |
+
const cached = this.teacherAudioCache.get(this.word);
|
| 864 |
+
if (cached) {
|
| 865 |
+
try { this.teacherWaveSurfer.load(cached); } catch { /* ignore */ }
|
| 866 |
+
}
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
this.cdr.detectChanges();
|
| 870 |
}
|
| 871 |
}
|
src/app/pronunciation/pronunciation.service.ts
CHANGED
|
@@ -18,34 +18,25 @@ export class ApiService {
|
|
| 18 |
|
| 19 |
constructor(private http: HttpClient) { }
|
| 20 |
|
|
|
|
| 21 |
generateTeacherAudio(word: string, reference?: File): Observable<{ audioUrl: string }> {
|
| 22 |
const url = `${this.pronBase}/generate_teacher_audio`;
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
if (reference) {
|
| 25 |
-
const form = new FormData();
|
| 26 |
-
form.append('word', word);
|
| 27 |
form.append('reference', reference, reference.name);
|
| 28 |
-
|
| 29 |
-
// Do NOT set Content-Type header for FormData; browser sets the boundary.
|
| 30 |
-
return this.http.post<any>(url, form).pipe(
|
| 31 |
-
map(res => ({ audioUrl: `${this.pronBase}/${res.audio_url}` })),
|
| 32 |
-
tap(r => console.log('[API] generateTeacherAudio (with ref) ->', r)),
|
| 33 |
-
catchError(err => {
|
| 34 |
-
console.error('[API] generateTeacherAudio error', err);
|
| 35 |
-
return throwError(() => err);
|
| 36 |
-
})
|
| 37 |
-
);
|
| 38 |
-
} else {
|
| 39 |
-
const headers = { 'Content-Type': 'application/json' };
|
| 40 |
-
return this.http.post<any>(url, { word }, { headers }).pipe(
|
| 41 |
-
map(res => ({ audioUrl: `${this.pronBase}/${res.audio_url}` })),
|
| 42 |
-
tap(r => console.log('[API] generateTeacherAudio ->', r)),
|
| 43 |
-
catchError(err => {
|
| 44 |
-
console.error('[API] generateTeacherAudio error', err);
|
| 45 |
-
return throwError(() => err);
|
| 46 |
-
})
|
| 47 |
-
);
|
| 48 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
// New: request teacher audio as raw bytes (no server-side persistent file)
|
|
@@ -53,30 +44,19 @@ export class ApiService {
|
|
| 53 |
generateTeacherAudioStream(word: string, reference?: File): Observable<Blob> {
|
| 54 |
const streamUrl = `${this.pronBase}/generate_teacher_audio_stream`;
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
} else {
|
| 70 |
-
const headers = { 'Content-Type': 'application/json' };
|
| 71 |
-
return this.http.post(streamUrl, { word }, { headers, responseType: 'blob' as 'json' }).pipe(
|
| 72 |
-
tap(() => console.log('[API] generateTeacherAudioStream')),
|
| 73 |
-
map((b: any) => b as Blob),
|
| 74 |
-
catchError(err => {
|
| 75 |
-
console.error('[API] generateTeacherAudioStream error', err);
|
| 76 |
-
return throwError(() => err);
|
| 77 |
-
})
|
| 78 |
-
);
|
| 79 |
-
}
|
| 80 |
}
|
| 81 |
|
| 82 |
// Helper: generate teacher audio then download it as a Blob
|
|
@@ -84,19 +64,11 @@ export class ApiService {
|
|
| 84 |
generateTeacherAudioBlob(word: string, reference?: File): Observable<{ audioUrl: string; blob: Blob }> {
|
| 85 |
const postUrl = `${this.pronBase}/generate_teacher_audio`;
|
| 86 |
|
| 87 |
-
const
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
form.append('word', word);
|
| 91 |
-
form.append('reference', reference, reference.name);
|
| 92 |
-
return this.http.post<any>(postUrl, form);
|
| 93 |
-
})()
|
| 94 |
-
: (() => {
|
| 95 |
-
const headers = { 'Content-Type': 'application/json' };
|
| 96 |
-
return this.http.post<any>(postUrl, { word }, { headers });
|
| 97 |
-
})();
|
| 98 |
|
| 99 |
-
return post
|
| 100 |
switchMap((res: any) => {
|
| 101 |
// backend returns e.g. "audio/teacher-xxxx.wav" — build full URL under /pron
|
| 102 |
const audioUrl = `${this.pronBase}/${res.audio_url}`;
|
|
|
|
| 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 |
+
// Always send FormData so Flask can read request.form (and accept optional reference file).
|
| 26 |
+
const form = new FormData();
|
| 27 |
+
form.append('word', word);
|
| 28 |
if (reference) {
|
|
|
|
|
|
|
| 29 |
form.append('reference', reference, reference.name);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
+
|
| 32 |
+
return this.http.post<any>(url, form).pipe(
|
| 33 |
+
map(res => ({ audioUrl: `${this.pronBase}/${res.audio_url}` })),
|
| 34 |
+
tap(r => console.log('[API] generateTeacherAudio ->', r)),
|
| 35 |
+
catchError(err => {
|
| 36 |
+
console.error('[API] generateTeacherAudio error', err);
|
| 37 |
+
return throwError(() => err);
|
| 38 |
+
})
|
| 39 |
+
);
|
| 40 |
}
|
| 41 |
|
| 42 |
// New: request teacher audio as raw bytes (no server-side persistent file)
|
|
|
|
| 44 |
generateTeacherAudioStream(word: string, reference?: File): Observable<Blob> {
|
| 45 |
const streamUrl = `${this.pronBase}/generate_teacher_audio_stream`;
|
| 46 |
|
| 47 |
+
const form = new FormData();
|
| 48 |
+
form.append('word', word);
|
| 49 |
+
if (reference) form.append('reference', reference, reference.name);
|
| 50 |
+
|
| 51 |
+
// Request blob response. Using 'blob' as 'json' keeps TS happy with HttpClient overloads.
|
| 52 |
+
return this.http.post(streamUrl, form, { responseType: 'blob' as 'json' }).pipe(
|
| 53 |
+
tap(() => console.log('[API] generateTeacherAudioStream')),
|
| 54 |
+
map((b: any) => b as Blob),
|
| 55 |
+
catchError(err => {
|
| 56 |
+
console.error('[API] generateTeacherAudioStream error', err);
|
| 57 |
+
return throwError(() => err);
|
| 58 |
+
})
|
| 59 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
// Helper: generate teacher audio then download it as a Blob
|
|
|
|
| 64 |
generateTeacherAudioBlob(word: string, reference?: File): Observable<{ audioUrl: string; blob: Blob }> {
|
| 65 |
const postUrl = `${this.pronBase}/generate_teacher_audio`;
|
| 66 |
|
| 67 |
+
const form = new FormData();
|
| 68 |
+
form.append('word', word);
|
| 69 |
+
if (reference) form.append('reference', reference, reference.name);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
return this.http.post<any>(postUrl, form).pipe(
|
| 72 |
switchMap((res: any) => {
|
| 73 |
// backend returns e.g. "audio/teacher-xxxx.wav" — build full URL under /pron
|
| 74 |
const audioUrl = `${this.pronBase}/${res.audio_url}`;
|
src/assets/audio/apple.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:36b263be26c27afa3d24960fc53ce9d6b5363714dda95e22b230ce8eb8c607c9
|
| 3 |
+
size 12408
|
src/assets/audio/ball.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e04526715ad4a8ec31f85eed6db1c377bf669e00a9bd2d551d19daec5460418f
|
| 3 |
+
size 10392
|
src/assets/audio/cat.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f28d8a06857382b8e7cd0b51712696ecb17d9fcdfd2a6928afb7b199b081e23e
|
| 3 |
+
size 12912
|
src/assets/audio/dog.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:988790d50de59b4422a425aa3ee3bc0b38e5e03ec14354d525a57cd313f6a23a
|
| 3 |
+
size 11184
|
src/assets/audio/egg.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f50aecc9e38956f68572510ee93c00223f4a3f684125b4580635f13963ebaa8f
|
| 3 |
+
size 9936
|
src/assets/audio/fish.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2bb0887b2bc028a2d454e6bd9bf4f3c80a5dc6bccfd9ea1417b3693f3424da24
|
| 3 |
+
size 9456
|
src/assets/audio/grapes.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fa8a75e85c457f948c0b810c16665667c99c095c71224c267f222b5dceabdb2f
|
| 3 |
+
size 13008
|
src/assets/audio/hat.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2a6e1d6d94fdbfdf3615e352715c652d72bc104fa3ff5ce7eb20b987b1a5468
|
| 3 |
+
size 10800
|
src/assets/audio/ice_cream.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:33cf0de8e2225a9eddfc0083c9a50e1f3ad5cf78a1b937d04694a250ed62ac83
|
| 3 |
+
size 14784
|
src/assets/audio/jar.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1cbb5f8dcc21cc4c78a3e2c61a6bb1a8f16eb2ed6e656a2b5b5c606a577d68cf
|
| 3 |
+
size 12432
|
src/assets/audio/kite.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7a1f086fbe0b46c4281d49cfdc4497259322f37c35aebee88e7ecba8bc507d47
|
| 3 |
+
size 12216
|
src/assets/audio/lion.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:97e8dfeb933de825ddccb7ab23616f0544fb1bf13dfbe7174d79dcd5769c3493
|
| 3 |
+
size 14112
|
src/assets/audio/moon.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:08b408ab5d8d19c22a4ff80da95e954553473ace618347e67a65525a1296743d
|
| 3 |
+
size 11016
|
src/assets/audio/nest.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1a6d84024ed543fc47619bce66d258cc9d89ed86d0960c6b02b3ff984947d2cf
|
| 3 |
+
size 11376
|
src/assets/audio/orange.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:28e970be8938cdae9ba382519ad8df8f59006474ae0dccc3a1026537b6916f6a
|
| 3 |
+
size 15408
|
src/assets/audio/original/ball.m4a
DELETED
|
Binary file (71.2 kB)
|
|
|
src/assets/audio/original/cat.m4a
DELETED
|
Binary file (61.9 kB)
|
|
|
src/assets/audio/original/dog.m4a
DELETED
|
Binary file (56.5 kB)
|
|
|
src/assets/audio/original/egg.m4a
DELETED
|
Binary file (74.9 kB)
|
|
|
src/assets/audio/original/fish.m4a
DELETED
|
Binary file (73.2 kB)
|
|
|
src/assets/audio/original/grapes.m4a
DELETED
|
Binary file (61.4 kB)
|
|
|
src/assets/audio/original/hat.m4a
DELETED
|
Binary file (75.3 kB)
|
|
|
src/assets/audio/original/ice_cream.m4a
DELETED
|
Binary file (80.4 kB)
|
|
|
src/assets/audio/original/jar.m4a
DELETED
|
Binary file (68.9 kB)
|
|
|
src/assets/audio/original/kite.m4a
DELETED
|
Binary file (59.2 kB)
|
|
|
src/assets/audio/original/lion.m4a
DELETED
|
Binary file (76 kB)
|
|
|
src/assets/audio/original/moon.m4a
DELETED
|
Binary file (81.4 kB)
|
|
|
src/assets/audio/original/nest.m4a
DELETED
|
Binary file (56.8 kB)
|
|
|
src/assets/audio/original/orange.m4a
DELETED
|
Binary file (87.8 kB)
|
|
|
src/assets/audio/original/pig.m4a
DELETED
|
Binary file (81.4 kB)
|
|
|
src/assets/audio/original/queen.m4a
DELETED
|
Binary file (74.4 kB)
|
|
|
src/assets/audio/original/rabbit.m4a
DELETED
|
Binary file (87.9 kB)
|
|
|
src/assets/audio/original/sun.m4a
DELETED
|
Binary file (76.6 kB)
|
|
|
src/assets/audio/original/tree.m4a
DELETED
|
Binary file (67.2 kB)
|
|
|
src/assets/audio/original/van.m4a
DELETED
|
Binary file (69.7 kB)
|
|
|
src/assets/audio/original/watch.m4a
DELETED
|
Binary file (76 kB)
|
|
|
src/assets/audio/original/xylophone.m4a
DELETED
|
Binary file (75.6 kB)
|
|
|
src/assets/audio/original/yarn.m4a
DELETED
|
Binary file (61.3 kB)
|
|
|
src/assets/audio/original/zebra.m4a
DELETED
|
Binary file (56.9 kB)
|
|
|
src/assets/audio/pig.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b73eb1b22b1e2615a6336c813a974ddc71e6e9e047453a92b5ab7c1714318007
|
| 3 |
+
size 9504
|
src/assets/audio/queen.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f822d6e09e355dd10e6ec9a86567da8c8865a4e9207711dc6c6f91fe73a0fae4
|
| 3 |
+
size 11568
|
src/assets/audio/rabbit.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5fce73c31d372c8e310bc5d3e47bb973b203410a3b599bb6322ecdf2fd096672
|
| 3 |
+
size 10776
|
src/assets/audio/sun.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b4e405e94eb7a7a9ce89b7b69fca109ccceac1b41c01fc165d882a8a9f1e7a8e
|
| 3 |
+
size 11904
|
src/assets/audio/tree.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9159be7faa03940ac783d589827efbf9666d3daf15e67a5953b7af8db2ac9703
|
| 3 |
+
size 13008
|
src/assets/audio/umbrella.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ec6c59c06372515022b65731f2f22804b469d342e8d477d1b7c13a030ec33650
|
| 3 |
+
size 14472
|