mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 03:45:33 +08:00
m
This commit is contained in:
39
app/assets/css/learn-theme.css
Normal file
39
app/assets/css/learn-theme.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* Shared Learn surface styling */
|
||||||
|
.learn-theme .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-theme .btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-theme .btn.primary {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--accent) 90%, transparent),
|
||||||
|
color-mix(in srgb, var(--accent) 70%, transparent)
|
||||||
|
);
|
||||||
|
color: #061318;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-theme .btn.soft {
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-theme .btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-theme .btn.mini {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-[#070d1a] text-white">
|
<div class="learn-theme min-h-screen bg-[#070d1a] text-white">
|
||||||
<header class="border-b border-white/5 bg-[#070d1a]/80 backdrop-blur">
|
<header class="border-b border-white/5 bg-[#070d1a]/80 backdrop-blur">
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-4 py-6 sm:px-6 md:px-8"
|
class="mx-auto flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-4 py-6 sm:px-6 md:px-8"
|
||||||
@@ -13,12 +13,9 @@
|
|||||||
<p class="text-sm text-white/60">Guided preflight briefing</p>
|
<p class="text-sm text-white/60">Guided preflight briefing</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink
|
<NuxtLink to="/learn" class="btn primary">
|
||||||
to="/learn"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-cyan-400/90 px-4 py-2 text-sm font-semibold text-[#07111f] shadow-lg shadow-cyan-500/30 transition hover:bg-cyan-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#070d1a]"
|
|
||||||
>
|
|
||||||
Enter Learn hub
|
Enter Learn hub
|
||||||
<v-icon icon="mdi-launch" size="18" class="text-[#07111f]" />
|
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -96,18 +93,20 @@
|
|||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="voice-toggle"
|
class="btn soft voice-toggle"
|
||||||
:class="{ active: voiceMode === 'text' }"
|
:class="{ active: voiceMode === 'text' }"
|
||||||
@click="setVoiceMode('text')"
|
@click="setVoiceMode('text')"
|
||||||
|
:aria-pressed="voiceMode === 'text'"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-text" size="18" class="text-cyan-200" />
|
<v-icon icon="mdi-text" size="18" class="text-cyan-200" />
|
||||||
Text only
|
Text only
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="voice-toggle"
|
class="btn soft voice-toggle"
|
||||||
:class="{ active: voiceMode === 'radio' }"
|
:class="{ active: voiceMode === 'radio' }"
|
||||||
@click="setVoiceMode('radio')"
|
@click="setVoiceMode('radio')"
|
||||||
|
:aria-pressed="voiceMode === 'radio'"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-radio-handheld" size="18" class="text-cyan-200" />
|
<v-icon icon="mdi-radio-handheld" size="18" class="text-cyan-200" />
|
||||||
Radio voice
|
Radio voice
|
||||||
@@ -140,14 +139,14 @@
|
|||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-cyan-400/90 px-3 py-2 text-sm font-semibold text-[#07111f] shadow-lg shadow-cyan-500/20 transition hover:bg-cyan-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1328]"
|
class="btn primary"
|
||||||
@click="handleRadioCheck"
|
@click="handleRadioCheck"
|
||||||
:disabled="speechLoading"
|
:disabled="speechLoading"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
:icon="speechLoading ? 'mdi-loading' : 'mdi-radio-check'"
|
:icon="speechLoading ? 'mdi-loading' : 'mdi-radio-check'"
|
||||||
size="18"
|
size="18"
|
||||||
class="text-[#07111f]"
|
class="text-[#061318]"
|
||||||
:class="{ 'animate-spin': speechLoading }"
|
:class="{ 'animate-spin': speechLoading }"
|
||||||
/>
|
/>
|
||||||
{{ speechLoading ? 'Checking…' : hasCompletedRadioCheck ? 'Radio check again' : 'Run radio check' }}
|
{{ speechLoading ? 'Checking…' : hasCompletedRadioCheck ? 'Radio check again' : 'Run radio check' }}
|
||||||
@@ -165,19 +164,16 @@
|
|||||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 via-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-[#07111f] shadow-lg shadow-cyan-500/30 transition hover:from-cyan-300 hover:via-sky-400 hover:to-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1328] disabled:cursor-not-allowed disabled:opacity-60"
|
class="btn primary"
|
||||||
@click="startTour"
|
@click="startTour"
|
||||||
:disabled="startDisabled"
|
:disabled="startDisabled"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#07111f]" />
|
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
|
||||||
Start guided tour
|
Start guided tour
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink
|
<NuxtLink to="/learn" class="btn ghost">
|
||||||
to="/learn"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-cyan-300 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1328]"
|
|
||||||
>
|
|
||||||
Skip to Learn
|
Skip to Learn
|
||||||
<v-icon icon="mdi-arrow-right" size="16" class="text-cyan-200" />
|
<v-icon icon="mdi-arrow-right" size="16" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-2 text-xs text-amber-200/80">
|
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-2 text-xs text-amber-200/80">
|
||||||
@@ -200,7 +196,7 @@
|
|||||||
<div class="flex items-center gap-3 text-xs text-white/70">
|
<div class="flex items-center gap-3 text-xs text-white/70">
|
||||||
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
|
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-gradient-to-r from-cyan-400 via-sky-500 to-indigo-500 transition-all duration-500"
|
class="progress-fill"
|
||||||
:style="{ width: `${stageProgress}%` }"
|
:style="{ width: `${stageProgress}%` }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +234,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="tourStarted"
|
v-if="tourStarted"
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-6 inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2 text-sm text-white/70 transition hover:border-cyan-300/60 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#080f1f]"
|
class="btn ghost mini mt-6"
|
||||||
@click="restartTour"
|
@click="restartTour"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-refresh" size="18" class="text-cyan-200" />
|
<v-icon icon="mdi-refresh" size="18" class="text-cyan-200" />
|
||||||
@@ -258,11 +254,11 @@
|
|||||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 via-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-[#07111f] shadow-lg shadow-cyan-500/30 transition hover:from-cyan-300 hover:via-sky-400 hover:to-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a1326] disabled:cursor-not-allowed disabled:opacity-60"
|
class="btn primary"
|
||||||
@click="startTour"
|
@click="startTour"
|
||||||
:disabled="startDisabled"
|
:disabled="startDisabled"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#07111f]" />
|
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
|
||||||
Start guided tour
|
Start guided tour
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +337,7 @@
|
|||||||
<div v-if="voiceMode === 'radio'" class="flex flex-wrap items-center gap-3">
|
<div v-if="voiceMode === 'radio'" class="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl border border-cyan-300/50 px-3 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-200 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0c162c]"
|
class="btn soft mini voice-replay"
|
||||||
@click="replayStageVoice"
|
@click="replayStageVoice"
|
||||||
:disabled="speechLoading"
|
:disabled="speechLoading"
|
||||||
>
|
>
|
||||||
@@ -364,7 +360,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canGoPrevious"
|
v-if="canGoPrevious"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-cyan-300 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0c162c]"
|
class="btn ghost"
|
||||||
@click="goToPrevious"
|
@click="goToPrevious"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-arrow-left" size="18" class="text-cyan-200" />
|
<v-icon icon="mdi-arrow-left" size="18" class="text-cyan-200" />
|
||||||
@@ -374,7 +370,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="voiceMode === 'radio'"
|
v-if="voiceMode === 'radio'"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl border border-white/15 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white/70 transition hover:border-cyan-300/60 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0c162c]"
|
class="btn ghost mini play-again"
|
||||||
@click="maybeSpeakForStage"
|
@click="maybeSpeakForStage"
|
||||||
:disabled="speechLoading"
|
:disabled="speechLoading"
|
||||||
>
|
>
|
||||||
@@ -384,19 +380,15 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canGoNext"
|
v-if="canGoNext"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 via-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-[#07111f] shadow-lg shadow-cyan-500/30 transition hover:from-cyan-300 hover:via-sky-400 hover:to-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0c162c]"
|
class="btn primary"
|
||||||
@click="goToNext"
|
@click="goToNext"
|
||||||
>
|
>
|
||||||
Next stop
|
Next stop
|
||||||
<v-icon icon="mdi-arrow-right" size="18" class="text-[#07111f]" />
|
<v-icon icon="mdi-arrow-right" size="18" class="text-[#061318]" />
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink
|
<NuxtLink v-else to="/learn" class="btn primary">
|
||||||
v-else
|
|
||||||
to="/learn"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-[#0c162c] shadow-lg shadow-cyan-500/20 transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0c162c]"
|
|
||||||
>
|
|
||||||
Enter Learn hub
|
Enter Learn hub
|
||||||
<v-icon icon="mdi-launch" size="18" class="text-[#0c162c]" />
|
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,6 +407,9 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useHead } from '#imports'
|
import { useHead } from '#imports'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { PizzicatoLite } from '~~/shared/utils/pizzicatoLite'
|
||||||
|
import { loadPizzicatoLite } from '~~/shared/utils/pizzicatoLite'
|
||||||
|
import { clampReadability, createNoiseGenerators, getReadabilityProfile } from '~~/shared/utils/radioEffects'
|
||||||
|
|
||||||
type VoiceMode = 'text' | 'radio'
|
type VoiceMode = 'text' | 'radio'
|
||||||
|
|
||||||
@@ -519,6 +514,13 @@ const speechError = ref<string | null>(null)
|
|||||||
const audioElement = ref<HTMLAudioElement | null>(null)
|
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||||
let speechRequestId = 0
|
let speechRequestId = 0
|
||||||
|
|
||||||
|
const isClient = typeof window !== 'undefined'
|
||||||
|
let speechContext: AudioContext | null = null
|
||||||
|
let pizzicatoLiteInstance: PizzicatoLite | null = null
|
||||||
|
type RadioSoundInstance = Awaited<ReturnType<PizzicatoLite['createSoundFromBase64']>>
|
||||||
|
let activeRadioSound: RadioSoundInstance | null = null
|
||||||
|
let activeRadioCleanup: Array<() => void> = []
|
||||||
|
|
||||||
const activeStage = computed(() => stages[stageIndex.value])
|
const activeStage = computed(() => stages[stageIndex.value])
|
||||||
const canGoPrevious = computed(() => stageIndex.value > 0)
|
const canGoPrevious = computed(() => stageIndex.value > 0)
|
||||||
const canGoNext = computed(() => stageIndex.value < stages.length - 1)
|
const canGoNext = computed(() => stageIndex.value < stages.length - 1)
|
||||||
@@ -533,8 +535,164 @@ const progressLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
const startDisabled = computed(() => voiceMode.value === 'radio' && !hasCompletedRadioCheck.value)
|
const startDisabled = computed(() => voiceMode.value === 'radio' && !hasCompletedRadioCheck.value)
|
||||||
|
|
||||||
|
async function ensureSpeechAudioContext(): Promise<AudioContext | null> {
|
||||||
|
if (!isClient) return null
|
||||||
|
|
||||||
|
const audioWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }
|
||||||
|
const AudioContextCtor = audioWindow.AudioContext || audioWindow.webkitAudioContext
|
||||||
|
if (!AudioContextCtor) return null
|
||||||
|
|
||||||
|
if (!speechContext || speechContext.state === 'closed') {
|
||||||
|
speechContext = new AudioContextCtor()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speechContext.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await speechContext.resume()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resume speech audio context', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return speechContext
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePizzicato(ctx: AudioContext | null): Promise<PizzicatoLite | null> {
|
||||||
|
if (!ctx) return null
|
||||||
|
if (!pizzicatoLiteInstance) {
|
||||||
|
pizzicatoLiteInstance = await loadPizzicatoLite()
|
||||||
|
}
|
||||||
|
return pizzicatoLiteInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseRadio(cleanups: Array<() => void>, sound: RadioSoundInstance | null) {
|
||||||
|
if (activeRadioSound === sound) {
|
||||||
|
activeRadioSound = null
|
||||||
|
}
|
||||||
|
if (activeRadioCleanup === cleanups) {
|
||||||
|
activeRadioCleanup = []
|
||||||
|
}
|
||||||
|
if (cleanups.length) {
|
||||||
|
cleanups.forEach(stop => {
|
||||||
|
try {
|
||||||
|
stop()
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup failures
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cleanups.length = 0
|
||||||
|
}
|
||||||
|
sound?.clearEffects()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playWithRadioEffect(base64: string, readability: number, requestId: number): Promise<boolean> {
|
||||||
|
let sound: RadioSoundInstance | null = null
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = await ensureSpeechAudioContext()
|
||||||
|
if (!ctx || speechRequestId !== requestId) return false
|
||||||
|
|
||||||
|
const pizzicato = await ensurePizzicato(ctx)
|
||||||
|
if (!pizzicato || speechRequestId !== requestId) return false
|
||||||
|
|
||||||
|
sound = await pizzicato.createSoundFromBase64(ctx, base64)
|
||||||
|
if (!sound || speechRequestId !== requestId) return false
|
||||||
|
|
||||||
|
const profile = getReadabilityProfile(readability)
|
||||||
|
const { Effects } = pizzicato
|
||||||
|
|
||||||
|
const highpass = new Effects.HighPassFilter(ctx, {
|
||||||
|
frequency: profile.eq.highpass,
|
||||||
|
q: profile.eq.highpassQ
|
||||||
|
})
|
||||||
|
const lowpass = new Effects.LowPassFilter(ctx, {
|
||||||
|
frequency: profile.eq.lowpass,
|
||||||
|
q: profile.eq.lowpassQ
|
||||||
|
})
|
||||||
|
|
||||||
|
sound.addEffect(highpass)
|
||||||
|
sound.addEffect(lowpass)
|
||||||
|
|
||||||
|
if (profile.eq.bandpass) {
|
||||||
|
sound.addEffect(
|
||||||
|
new Effects.BandPassFilter(ctx, {
|
||||||
|
frequency: profile.eq.bandpass.frequency,
|
||||||
|
q: profile.eq.bandpass.q
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.presence) {
|
||||||
|
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.distortions.forEach(amount => {
|
||||||
|
sound?.addEffect(new Effects.Distortion(ctx, { amount }))
|
||||||
|
})
|
||||||
|
|
||||||
|
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
|
||||||
|
|
||||||
|
if (profile.tremolos) {
|
||||||
|
profile.tremolos.forEach(tremolo => {
|
||||||
|
sound?.addEffect(new Effects.Tremolo(ctx, tremolo))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sound.setVolume(profile.gain)
|
||||||
|
|
||||||
|
const playbackDuration = Math.max(0.1, sound.duration)
|
||||||
|
cleanups.push(...createNoiseGenerators(ctx, playbackDuration, profile, readability))
|
||||||
|
|
||||||
|
if (speechRequestId !== requestId) {
|
||||||
|
releaseRadio(cleanups, sound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRadioSound = sound
|
||||||
|
activeRadioCleanup = cleanups
|
||||||
|
|
||||||
|
if (speechRequestId !== requestId) {
|
||||||
|
releaseRadio(cleanups, sound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackPromise = sound.play()
|
||||||
|
if (speechRequestId === requestId) {
|
||||||
|
speechPlaying.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackPromise.finally(() => {
|
||||||
|
const shouldUpdateState = speechRequestId === requestId
|
||||||
|
releaseRadio(cleanups, sound)
|
||||||
|
if (shouldUpdateState) {
|
||||||
|
speechPlaying.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
releaseRadio(cleanups, sound)
|
||||||
|
console.error('Failed to apply radio effect', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stopAudio(advanceRequestId = true) {
|
function stopAudio(advanceRequestId = true) {
|
||||||
speechPlaying.value = false
|
speechPlaying.value = false
|
||||||
|
|
||||||
|
if (activeRadioSound || activeRadioCleanup.length) {
|
||||||
|
const sound = activeRadioSound
|
||||||
|
if (sound) {
|
||||||
|
try {
|
||||||
|
sound.stop()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to stop radio voice', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releaseRadio(activeRadioCleanup, sound ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
if (audioElement.value) {
|
if (audioElement.value) {
|
||||||
try {
|
try {
|
||||||
audioElement.value.pause()
|
audioElement.value.pause()
|
||||||
@@ -544,6 +702,7 @@ function stopAudio(advanceRequestId = true) {
|
|||||||
}
|
}
|
||||||
audioElement.value = null
|
audioElement.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (advanceRequestId) {
|
if (advanceRequestId) {
|
||||||
speechRequestId += 1
|
speechRequestId += 1
|
||||||
}
|
}
|
||||||
@@ -582,6 +741,17 @@ async function speak(text: string, stageId?: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mime = response?.audio?.mime || 'audio/wav'
|
const mime = response?.audio?.mime || 'audio/wav'
|
||||||
|
const readability = clampReadability(radioLevel.value)
|
||||||
|
|
||||||
|
const playedWithEffects = await playWithRadioEffect(base64, readability, requestId)
|
||||||
|
if (requestId !== speechRequestId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playedWithEffects) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const audio = new Audio(`data:${mime};base64,${base64}`)
|
const audio = new Audio(`data:${mime};base64,${base64}`)
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
@@ -614,6 +784,10 @@ async function speak(text: string, stageId?: string): Promise<boolean> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (speechRequestId === requestId) {
|
if (speechRequestId === requestId) {
|
||||||
speechError.value = 'Audio was blocked by the browser. Click anywhere on the page and try again.'
|
speechError.value = 'Audio was blocked by the browser. Click anywhere on the page and try again.'
|
||||||
|
speechPlaying.value = false
|
||||||
|
if (audioElement.value === audio) {
|
||||||
|
audioElement.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -723,11 +897,43 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.voice-toggle {
|
.voice-toggle {
|
||||||
@apply inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2 text-sm font-semibold text-white/70 transition hover:border-cyan-300/60 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1328];
|
transition: border-color 0.25s ease, background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-toggle .v-icon {
|
||||||
|
color: color-mix(in srgb, var(--accent) 70%, white 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-toggle.active {
|
.voice-toggle.active {
|
||||||
@apply border-cyan-300/60 bg-cyan-400/10 text-white;
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 26%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-toggle.active .v-icon {
|
||||||
|
color: color-mix(in srgb, var(--accent) 85%, white 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-replay {
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-replay:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-again {
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(90deg, rgba(34, 211, 238, 0.85), rgba(14, 165, 233, 0.92));
|
||||||
|
box-shadow: 0 0 18px rgba(14, 165, 233, 0.35);
|
||||||
|
transition: width 0.45s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-enter-active,
|
.fade-slide-enter-active,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scene">
|
<div class="scene learn-theme">
|
||||||
<!-- APP BAR -->
|
<!-- APP BAR -->
|
||||||
<header class="hud" role="banner">
|
<header class="hud" role="banner">
|
||||||
<nav class="hud-inner" aria-label="Global">
|
<nav class="hud-inner" aria-label="Global">
|
||||||
@@ -4399,42 +4399,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--text) 10%, transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 90%, transparent), color-mix(in srgb, var(--accent) 70%, transparent));
|
|
||||||
color: #061318;
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 60%, transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.soft {
|
|
||||||
background: color-mix(in srgb, var(--text) 8%, transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.ghost {
|
|
||||||
background: transparent
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.mini {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HUB tiles */
|
/* HUB tiles */
|
||||||
.hub-head {
|
.hub-head {
|
||||||
margin: 6px 0 10px
|
margin: 6px 0 10px
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
css: [
|
css: [
|
||||||
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
|
'~/assets/css/global.css',
|
||||||
|
'~/assets/css/opensquawk-glass.css',
|
||||||
|
'~/assets/css/learn-theme.css'
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user