Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/app/core/services/misc/text-to-speech.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Injectable } from '@angular/core'

import { ConfigKeys } from '../../../shared/enums/config'
import { RemoteConfigService } from '../config/remote-config.service'

type TtsProvider = 'browser'

@Injectable({
providedIn: 'root'
})
export class TextToSpeechService {
private utterance: SpeechSynthesisUtterance | undefined
private isSpeaking = false
private readonly providerKey = new ConfigKeys('text_to_speech_provider')
private readonly enabledKey = new ConfigKeys('text_to_speech_enabled')

constructor(private remoteConfig: RemoteConfigService) { }

isSupported(): boolean {
return typeof window !== 'undefined' && 'speechSynthesis' in window
}

getSpeakingState(): boolean {
return this.isSpeaking
}

/** True when the configured provider is available. */
async isReadAloudAvailable(): Promise<boolean> {
const isEnabled = await this.isFeatureEnabled()
if (!isEnabled) return false

const provider = await this.getProvider()
if (provider === 'browser') return this.isSupported()
return false
}

async speak(text: string, lang?: string): Promise<void> {
if (!text?.trim()) return Promise.resolve()
const isEnabled = await this.isFeatureEnabled()
if (!isEnabled) return Promise.resolve()

this.stop()

const provider = await this.getProvider()
if (provider === 'browser') return this.speakBrowser(text, lang)
return Promise.resolve()
}

stop() {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel()
}
this.utterance = undefined
this.isSpeaking = false
}

private async getProvider(): Promise<TtsProvider> {
// Uses a free-form key so provider switching can be remote-configured later.
const conf = await this.remoteConfig.read()
const provider = (await conf.getOrDefault(this.providerKey, 'browser')).trim().toLowerCase()

return provider === 'browser' ? 'browser' : 'browser'
}

private async isFeatureEnabled(): Promise<boolean> {
const conf = await this.remoteConfig.read()
const enabled = (await conf.getOrDefault(this.enabledKey, 'true')).trim().toLowerCase()
return enabled !== 'false' && enabled !== '0' && enabled !== 'no'
}

private speakBrowser(text: string, lang?: string): Promise<void> {
if (!this.isSupported()) return Promise.resolve()

this.utterance = new SpeechSynthesisUtterance(text)
if (lang) this.utterance.lang = lang
this.isSpeaking = true

return new Promise(resolve => {
this.utterance.onend = () => {
this.isSpeaking = false
this.utterance = undefined
resolve()
}
this.utterance.onerror = () => {
this.isSpeaking = false
this.utterance = undefined
resolve()
}
window.speechSynthesis.speak(this.utterance)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<ion-grid class="guided-audio-grid">

<!-- ── Image stimulus ─────────────────────────────────────────── -->
<ion-row *ngIf="image" class="stimulus-row">
<ion-col size="12" class="ion-text-center">
<img [src]="image" alt="" class="stimulus-image" />
</ion-col>
</ion-row>

<!-- ── Prompt text + optional replay button ───────────────────── -->
<ion-row class="prompt-row" *ngIf="shouldShowPromptRow">
<ion-col size="12">
<div class="prompt-card" [class.prompt-speaking]="state === 'speaking'">
<h2 *ngIf="shouldShowFieldLabel" dir="auto" class="prompt-text">{{ text }}</h2>
<ion-button *ngIf="config?.allow_replay_prompt && !hasReplayedPrompt && state === 'idle'" size="small"
class="bt replay-btn" (click)="replayPrompt()">
<ion-icon slot="start" name="volume-high-outline"></ion-icon>
{{ 'GUIDED_AUDIO_REPLAY_PROMPT' | translate }}
</ion-button>
</div>
</ion-col>
</ion-row>

<!-- ── State-driven action area ───────────────────────────────── -->
<ion-row class="action-row ion-justify-content-center ion-align-items-center">
<ion-col size="12" class="ion-text-center">

<!-- Speaking -->
<div *ngIf="state === 'speaking'" class="state-block">
<div class="speaking-indicator">
<ion-icon name="volume-high-outline" class="speaking-icon"></ion-icon>
<p>{{ 'GUIDED_AUDIO_SPEAKING' | translate }}</p>
</div>
</div>

<!-- Recording -->
<div *ngIf="state === 'recording'" class="state-block">
<div class="recording-indicator">
<div class="recording-dot"></div>
<p class="recording-label">{{ 'GUIDED_AUDIO_RECORDING' | translate }}</p>
<p *ngIf="(config?.record_seconds ?? 0) > 0" class="timer-label">
{{ recordSecondsRemaining }}s
</p>
</div>
<ion-button class="bt bt--full bt-stop" (click)="handleManualStop()">
{{ 'GUIDED_AUDIO_STOP_RECORDING' | translate }}
</ion-button>
</div>

<!-- Recorded – awaiting confirmation -->
<div *ngIf="state === 'recorded'" class="state-block">
<ion-icon name="checkmark-circle-outline" class="done-icon"></ion-icon>
<p class="recorded-label">{{ 'GUIDED_AUDIO_RECORDED' | translate }}</p>
<div class="confirm-buttons">
<ion-button class="bt bt--full" (click)="confirmRecording()">
{{ 'GUIDED_AUDIO_CONFIRM' | translate }}
</ion-button>
</div>
</div>

<!-- Idle – manual start -->
<div *ngIf="state === 'idle' && shouldAllowManualStart" class="state-block">
<ion-button class="bt bt--full" (click)="startRecording()">
{{ 'GUIDED_AUDIO_START_RECORDING' | translate }}
</ion-button>
</div>

</ion-col>
</ion-row>

</ion-grid>
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
ion-grid.guided-audio-grid {
height: 100% !important;
display: flex;
flex-direction: column;
padding: 0 8px;

// Keep guided-audio buttons visually consistent with app defaults.
ion-button.bt {
--background: var(--cl-primary-20);
--border-color: white;
--border-width: 2px;
--border-style: solid;
--border-radius: var(--bt-height);
--box-shadow: none;
}
}

// ── Image stimulus ────────────────────────────────────────────────────────────

.stimulus-row {
flex: 0 1 auto;
max-height: 42%;
}

.stimulus-image {
max-width: 100%;
max-height: 38vh;
object-fit: contain;
border-radius: 8px;
margin-bottom: 4px;
}

// ── Prompt ────────────────────────────────────────────────────────────────────

.prompt-row {
flex: 0 0 auto;
}

.prompt-card {
padding: 12px 4px 8px;
border-radius: 8px;
transition: background 0.3s ease;

&.prompt-speaking {
background-color: var(--cl-primary-10);
}

h2.prompt-text {
font-size: 18px;
line-height: 1.5;
margin: 0 0 6px;
}

.replay-btn {
margin-top: 2px;
}
}

// ── Action area ───────────────────────────────────────────────────────────────

.action-row {
flex: 1 1 auto;
}

.state-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 8px 0;
width: 100%;
}

// Speaking state
.speaking-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;

.speaking-icon {
font-size: 44px;
color: var(--ion-color-primary);
animation: pulse 1.4s ease-in-out infinite;
}

p {
margin: 0;
font-size: 15px;
color: var(--ion-color-medium);
}
}

// Countdown state
.countdown-ring {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--ion-color-primary);
display: flex;
align-items: center;
justify-content: center;
animation: pulse 0.9s ease-in-out infinite;

.countdown-number {
font-size: 44px;
font-weight: bold;
color: white;
line-height: 1;
}
}

// Recording state
.recording-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;

.recording-dot {
width: 22px;
height: 22px;
border-radius: 50%;
background-color: var(--cl-danger-40);
animation: blink 1s step-start infinite;
}

.recording-label {
margin: 0;
font-size: 15px;
color: var(--ion-color-medium);
}

.timer-label {
margin: 0;
font-size: 28px;
font-weight: bold;
color: var(--ion-color-primary);
}
}

.bt-stop {
background-color: var(--cl-danger-40);
border-radius: var(--bt-height);
--background: var(--cl-danger-40);
--border-radius: var(--bt-height);
}

// Recorded state
.done-icon {
font-size: 52px;
color: var(--ion-color-success);
}

.recorded-label {
margin: 0;
font-size: 15px;
color: var(--ion-color-medium);
}

.confirm-buttons {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
width: 100%;
}

// ── Skip / reason panel ───────────────────────────────────────────────────────

.skip-row {
flex: 1 1 auto;

.skip-question {
font-size: 17px;
font-weight: 500;
margin: 12px 0 10px;
}

.skip-list {
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;

ion-item {
--background: var(--cl-primary-10);
--border-color: var(--ion-color-light);
cursor: pointer;

&.selected {
--background: rgba(var(--ion-color-primary-rgb), 0.12);
}
}
}

.cancel-skip-btn {
margin-top: 6px;
}
}

// ── Shared button helpers ─────────────────────────────────────────────────────

// ── Animations ────────────────────────────────────────────────────────────────

@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.08);
opacity: 0.82;
}
}

@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
Loading
Loading