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 |
+
}
|