feat: add speech synthesis demo
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
VITE_CUTOS_BROKER_URL=mock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_CUTOS_BROKER_URL=localhost
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
release
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Demo Speech Synthesis
|
||||||
|
|
||||||
|
CUTOS 4.0 LWA demo for `@cutos/speech-synthesis`. It uses the browser-side Web Speech API and Windows-installed voices. It does not require a CUTOS Provider, Device Driver, or Gateway service.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Build the sibling SDK first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ../sdk
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ../demo-speech-synthesis
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## LWA Build And Release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx cutos lwa build
|
||||||
|
npx cutos login --username office
|
||||||
|
npx cutos lwa upload
|
||||||
|
npx cutos lwa list demo-speech-synthesis
|
||||||
|
npx cutos lwa publish --device <device-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The release version is managed only by `public/config.json`.
|
||||||
|
|
||||||
|
## Offline Validation
|
||||||
|
|
||||||
|
1. Run the LWA on a Windows CUTOS Runtime.
|
||||||
|
2. Confirm **Speech API Ready** and at least one listed voice.
|
||||||
|
3. Prefer a voice marked **Local** for offline operation.
|
||||||
|
4. Enter text and click **Speak**; the Activity card should show start and completion events.
|
||||||
|
|
||||||
|
If no voices are listed, install a Windows speech voice pack and restart the Runtime. Some WebView implementations require speech to start from a user interaction, so this demo always uses an explicit Speak button.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#20262c" />
|
||||||
|
<title>CUTOS Speech Synthesis</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "demo-speech-synthesis",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "cutos lwa build",
|
||||||
|
"build:app": "vue-tsc --noEmit && vite build",
|
||||||
|
"package": "cutos lwa package",
|
||||||
|
"validate": "cutos lwa validate",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cutos/core": "^4.0.8",
|
||||||
|
"@cutos/speech-synthesis": "^4.0.0",
|
||||||
|
"vue": "^3.4.31"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"postcss-import": "^16.1.0",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.3.4",
|
||||||
|
"vue-tsc": "^2.0.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'postcss-import': {},
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "demo-speech-synthesis",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "CUTOS 4.0 offline Web Speech API text-to-speech demo.",
|
||||||
|
"params": {
|
||||||
|
"title": "CUTOS Speech Synthesis",
|
||||||
|
"subtitle": "Web Speech API · Windows Offline TTS",
|
||||||
|
"host": "localhost"
|
||||||
|
},
|
||||||
|
"drvDependencies": {}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { CoreAPI } from '@cutos/core'
|
||||||
|
import { SpeechSynthesizer, type SpeechEvent, type SpeechVoiceInfo } from '@cutos/speech-synthesis'
|
||||||
|
import CutosTopbar from '@/components/CutosTopbar.vue'
|
||||||
|
import { loadConfig } from '@/utils/config'
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
time: string
|
||||||
|
level: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreVersion = ref('')
|
||||||
|
const lwaVersion = ref('')
|
||||||
|
const title = ref('CUTOS Speech Synthesis')
|
||||||
|
const subtitle = ref('Web Speech API · Windows Offline TTS')
|
||||||
|
const supported = ref(false)
|
||||||
|
const ready = ref(false)
|
||||||
|
const speaking = ref(false)
|
||||||
|
const paused = ref(false)
|
||||||
|
const voices = ref<SpeechVoiceInfo[]>([])
|
||||||
|
const language = ref('zh')
|
||||||
|
const localOnly = ref(false)
|
||||||
|
const voiceURI = ref('')
|
||||||
|
const text = ref('欢迎使用 CUTOS。此语音由 Windows 本地 Web Speech API 合成。')
|
||||||
|
const rate = ref(1)
|
||||||
|
const pitch = ref(1)
|
||||||
|
const volume = ref(1)
|
||||||
|
const activity = ref<Activity[]>([])
|
||||||
|
let speech: SpeechSynthesizer | null = null
|
||||||
|
let unsubscribe: (() => void)[] = []
|
||||||
|
|
||||||
|
const displayedVoices = computed(() => voices.value.filter(voice => {
|
||||||
|
const matchesLanguage = !language.value || voice.lang.toLowerCase().startsWith(language.value.toLowerCase())
|
||||||
|
return matchesLanguage && (!localOnly.value || voice.localService)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedVoice = computed(() => voices.value.find(voice => voice.voiceURI === voiceURI.value) || null)
|
||||||
|
const localVoiceCount = computed(() => voices.value.filter(voice => voice.localService).length)
|
||||||
|
|
||||||
|
function addActivity(message: string, level: Activity['level'] = 'info') {
|
||||||
|
activity.value.unshift({ time: new Date().toLocaleTimeString(), level, message })
|
||||||
|
activity.value = activity.value.slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshState() {
|
||||||
|
if (!speech) return
|
||||||
|
const state = speech.getState()
|
||||||
|
speaking.value = state.speaking
|
||||||
|
paused.value = state.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreferredVoice() {
|
||||||
|
if (displayedVoices.value.some(voice => voice.voiceURI === voiceURI.value)) return
|
||||||
|
const preferred = displayedVoices.value.find(voice => voice.localService && voice.default)
|
||||||
|
|| displayedVoices.value.find(voice => voice.localService)
|
||||||
|
|| displayedVoices.value.find(voice => voice.default)
|
||||||
|
|| displayedVoices.value[0]
|
||||||
|
voiceURI.value = preferred?.voiceURI || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSpeechEvent(event: SpeechEvent) {
|
||||||
|
refreshState()
|
||||||
|
if (event.type === 'boundary') return
|
||||||
|
const voice = selectedVoice.value?.name || 'default voice'
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
start: `Speech started with ${voice}.`,
|
||||||
|
end: 'Speech completed.',
|
||||||
|
pause: 'Speech paused.',
|
||||||
|
resume: 'Speech resumed.',
|
||||||
|
cancel: 'Speech cancelled.',
|
||||||
|
error: `Speech error: ${event.error || 'unknown error'}.`
|
||||||
|
}
|
||||||
|
const level: Activity['level'] = event.type === 'error' ? 'error' : event.type === 'cancel' ? 'warning' : 'success'
|
||||||
|
if (labels[event.type]) addActivity(labels[event.type], level)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeSpeech() {
|
||||||
|
supported.value = SpeechSynthesizer.isSupported()
|
||||||
|
if (!supported.value) {
|
||||||
|
addActivity('Web Speech API is unavailable in this LWA runtime.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
speech = new SpeechSynthesizer()
|
||||||
|
voices.value = await speech.ready()
|
||||||
|
ready.value = true
|
||||||
|
selectPreferredVoice()
|
||||||
|
for (const event of ['start', 'end', 'pause', 'resume', 'cancel', 'error'] as const) {
|
||||||
|
unsubscribe.push(speech.on(event, onSpeechEvent))
|
||||||
|
}
|
||||||
|
addActivity(`Speech API ready. ${voices.value.length} voice(s), ${localVoiceCount.value} local.`, voices.value.length ? 'success' : 'warning')
|
||||||
|
if (!voices.value.length) addActivity('No voice was returned. Install a Windows speech voice and restart CUTOS Runtime.', 'warning')
|
||||||
|
} catch (error) {
|
||||||
|
addActivity(error instanceof Error ? error.message : String(error), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speak() {
|
||||||
|
if (!speech || !ready.value) return
|
||||||
|
try {
|
||||||
|
await speech.speak({ text: text.value, lang: selectedVoice.value?.lang || 'zh-CN', voiceURI: voiceURI.value || undefined, rate: rate.value, pitch: pitch.value, volume: volume.value })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (!message.includes('cancelled')) addActivity(message, 'error')
|
||||||
|
} finally {
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseOrResume() {
|
||||||
|
if (!speech) return
|
||||||
|
if (paused.value) speech.resume()
|
||||||
|
else speech.pause()
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
speech?.cancel()
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const config = await loadConfig()
|
||||||
|
const params = config.params
|
||||||
|
title.value = String(params.title || title.value)
|
||||||
|
subtitle.value = String(params.subtitle || subtitle.value)
|
||||||
|
lwaVersion.value = config.version
|
||||||
|
try {
|
||||||
|
await CoreAPI.init(String(import.meta.env.VITE_CUTOS_BROKER_URL || params.host || 'localhost'))
|
||||||
|
coreVersion.value = CoreAPI.getVersion()
|
||||||
|
} catch (error) {
|
||||||
|
addActivity(`CUTOS Core unavailable: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
||||||
|
}
|
||||||
|
await initializeSpeech()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([language, localOnly], selectPreferredVoice)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribe.forEach(off => off())
|
||||||
|
speech?.cancel()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<CutosTopbar :title="title" :subtitle="subtitle" :core-version="coreVersion" :lwa-version="lwaVersion" :supported="supported" />
|
||||||
|
<main class="page">
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">BROWSER-SIDE SDK</p>
|
||||||
|
<h2>Offline Text to Speech</h2>
|
||||||
|
<p>Uses the Web Speech API and voices installed in Windows. No CUTOS Provider or network connection is required.</p>
|
||||||
|
</div>
|
||||||
|
<div class="sdk-badge"><span>SDK</span><strong>@cutos/speech-synthesis 4.0.0</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="card compose-card">
|
||||||
|
<div class="card-heading"><div><p class="eyebrow">SPEECH</p><h3>Compose</h3></div><span class="state" :class="{ active: speaking, paused }">{{ paused ? 'Paused' : speaking ? 'Speaking' : ready ? 'Ready' : 'Loading' }}</span></div>
|
||||||
|
<label class="field-label" for="speech-text">Text</label>
|
||||||
|
<textarea id="speech-text" v-model="text" :disabled="!ready" maxlength="1000" rows="6"></textarea>
|
||||||
|
<div class="textarea-meta"><span>{{ text.length }} / 1000</span><span>Call Speak from a user action.</span></div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="primary" :disabled="!ready || !text.trim()" @click="speak">Speak</button>
|
||||||
|
<button :disabled="!speaking" @click="pauseOrResume">{{ paused ? 'Resume' : 'Pause' }}</button>
|
||||||
|
<button :disabled="!speaking && !paused" @click="cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card voices-card">
|
||||||
|
<div class="card-heading"><div><p class="eyebrow">WINDOWS VOICES</p><h3>Voice</h3></div><span class="count">{{ voices.length }} available</span></div>
|
||||||
|
<div class="filter-row">
|
||||||
|
<label><span>Language</span><select v-model="language"><option value="">All languages</option><option value="zh">Chinese</option><option value="en">English</option></select></label>
|
||||||
|
<label class="switch"><input v-model="localOnly" type="checkbox" /><span>Local voices only</span></label>
|
||||||
|
</div>
|
||||||
|
<label class="field-label" for="voice">Selected voice</label>
|
||||||
|
<select id="voice" v-model="voiceURI" :disabled="!ready || !displayedVoices.length">
|
||||||
|
<option v-if="!displayedVoices.length" value="">No matching voice</option>
|
||||||
|
<option v-for="voice in displayedVoices" :key="voice.voiceURI" :value="voice.voiceURI">{{ voice.name }} · {{ voice.lang }}{{ voice.localService ? ' · Local' : '' }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="voice-note"><strong>{{ localVoiceCount }}</strong> voices report local service. The flag is supplied by the Windows WebView runtime.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card controls-card">
|
||||||
|
<div class="card-heading"><div><p class="eyebrow">PARAMETERS</p><h3>Voice controls</h3></div></div>
|
||||||
|
<label class="range"><span>Rate <b>{{ rate.toFixed(1) }}</b></span><input v-model.number="rate" type="range" min="0.5" max="2" step="0.1" /></label>
|
||||||
|
<label class="range"><span>Pitch <b>{{ pitch.toFixed(1) }}</b></span><input v-model.number="pitch" type="range" min="0" max="2" step="0.1" /></label>
|
||||||
|
<label class="range"><span>Volume <b>{{ Math.round(volume * 100) }}%</b></span><input v-model.number="volume" type="range" min="0" max="1" step="0.05" /></label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card activity-card">
|
||||||
|
<div class="card-heading"><div><p class="eyebrow">ACTIVITY</p><h3>Speech events</h3></div><button class="text-button" @click="activity = []">Clear</button></div>
|
||||||
|
<div class="activity-list">
|
||||||
|
<p v-if="!activity.length" class="empty">Waiting for Web Speech API events.</p>
|
||||||
|
<div v-for="item in activity" :key="`${item.time}-${item.message}`" class="activity-item" :class="item.level"><time>{{ item.time }}</time><span>{{ item.message }}</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import cutosLogo from '@/assets/cutos.png'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
coreVersion: string
|
||||||
|
lwaVersion: string
|
||||||
|
supported: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<img :src="cutosLogo" alt="CUTOS" />
|
||||||
|
<div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-cluster">
|
||||||
|
<span class="version-tag">Core {{ coreVersion || '—' }}</span>
|
||||||
|
<span class="version-tag">LWA {{ lwaVersion || '—' }}</span>
|
||||||
|
<span class="connection" :class="{ online: supported }"><i></i>{{ supported ? 'Speech API Ready' : 'Speech API Unavailable' }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topbar { min-height: 82px; padding: 14px clamp(18px, 4vw, 52px); display: flex; align-items: center; justify-content: space-between; gap: 24px; color: #f8fafb; background: #20262c; border-bottom: 4px solid #16a394; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 15px; min-width: 0; }.brand img { width: 42px; height: 42px; object-fit: contain; }.brand h1 { margin: 0; font-size: 20px; line-height: 1.2; font-weight: 700; }.brand p { margin: 4px 0 0; font-size: 12px; color: #aeb8bf; }
|
||||||
|
.status-cluster { display: flex; align-items: center; justify-content: flex-end; gap: 9px; flex-wrap: wrap; }.version-tag,.connection { height: 30px; display: inline-flex; align-items: center; padding: 0 10px; font-size: 12px; white-space: nowrap; border: 1px solid #4b555d; border-radius: 4px; background: #2b3339; }.connection i { width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: #e06b65; }.connection.online i { background: #42c6a8; box-shadow: 0 0 0 3px rgba(66,198,168,.14); }
|
||||||
|
@media (max-width: 640px) { .topbar { align-items: flex-start; flex-direction: column; gap: 12px; }.status-cluster { justify-content: flex-start; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
:root { font-family: Inter, "Segoe UI", Arial, sans-serif; color: #20262c; background: #eef1f3; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
|
||||||
|
* { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; } button, input, select, textarea { font: inherit; }.app-shell { min-height: 100vh; background: #eef1f3; }.page { width: min(1420px, 100%); margin: 0 auto; padding: 28px clamp(18px, 3vw, 42px) 36px; }
|
||||||
|
.hero { display:flex; justify-content:space-between; align-items:flex-end; gap:24px; margin: 0 0 22px; }.eyebrow { margin:0 0 7px; color:#168c81; font-size:11px; font-weight:800; letter-spacing:.13em; }.hero h2,.card h3 { margin:0; }.hero h2 { font-size:28px; line-height:1.2; }.hero p:not(.eyebrow) { max-width:690px; margin:9px 0 0; color:#627079; line-height:1.55; }.sdk-badge { min-width:245px; display:flex; flex-direction:column; gap:4px; padding:12px 14px; border:1px solid #ced8dd; border-radius:8px; background:#fff; font-size:12px; }.sdk-badge span { color:#71808a; font-size:10px; font-weight:800; letter-spacing:.1em; }.sdk-badge strong { color:#26343b; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:18px; }.card { min-width:0; padding:20px; border:1px solid #d6dfe3; border-radius:10px; background:#fff; box-shadow:0 1px 2px rgba(25,42,50,.04); }.compose-card { grid-column:span 7; }.voices-card { grid-column:span 5; }.controls-card { grid-column:span 4; }.activity-card { grid-column:span 8; }.card-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:18px; }.card h3 { font-size:18px; }.state,.count { padding:5px 8px; border-radius:999px; background:#edf1f3; color:#65737b; font-size:11px; font-weight:700; }.state.active { background:#def5ee; color:#137867; }.state.paused { background:#fff1d8; color:#9a6413; }
|
||||||
|
.field-label { display:block; margin:0 0 7px; color:#53636b; font-size:12px; font-weight:700; }textarea,select { width:100%; border:1px solid #bbc9cf; border-radius:6px; background:#fff; color:#26343b; outline:none; }textarea { resize:vertical; padding:11px; line-height:1.55; }select { height:38px; padding:0 10px; }textarea:focus,select:focus { border-color:#16a394; box-shadow:0 0 0 3px rgba(22,163,148,.12); }.textarea-meta { display:flex; justify-content:space-between; gap:12px; margin:7px 0 16px; color:#839098; font-size:11px; }.action-row { display:flex; flex-wrap:wrap; gap:9px; }button { min-height:36px; padding:0 14px; border:1px solid #b9c6cc; border-radius:6px; background:#fff; color:#35444b; cursor:pointer; font-weight:700; font-size:13px; }button:hover:not(:disabled) { border-color:#71848d; }button:disabled { cursor:not-allowed; opacity:.45; }.primary { border-color:#168f81; background:#16a394; color:#fff; }.primary:hover:not(:disabled) { background:#118b7d; }.text-button { min-height:0; padding:0; border:0; color:#168c81; background:transparent; }
|
||||||
|
.filter-row { display:flex; align-items:flex-end; gap:16px; margin-bottom:16px; }.filter-row label:first-child { flex:1; }.filter-row label > span { display:block; margin-bottom:7px; color:#53636b; font-size:12px; font-weight:700; }.switch { display:flex; align-items:center; gap:7px; height:38px; color:#53636b; font-size:12px; white-space:nowrap; }.switch input { width:15px; height:15px; accent-color:#16a394; }.voice-note { margin:13px 0 0; color:#728087; font-size:12px; line-height:1.45; }.voice-note strong { color:#168c81; }
|
||||||
|
.range { display:block; margin:0 0 21px; }.range:last-child { margin-bottom:0; }.range span { display:flex; justify-content:space-between; margin-bottom:8px; color:#53636b; font-size:12px; font-weight:700; }.range b { color:#168c81; }.range input { width:100%; accent-color:#16a394; }.activity-list { max-height:197px; overflow:auto; border-top:1px solid #e7edef; }.activity-item { display:grid; grid-template-columns:75px 1fr; gap:10px; padding:9px 1px; border-bottom:1px solid #edf1f3; font-size:12px; line-height:1.4; }.activity-item time { color:#87949b; }.activity-item.success span { color:#157969; }.activity-item.warning span { color:#9a6413; }.activity-item.error span { color:#b34d49; }.empty { margin:17px 0; color:#87949b; font-size:12px; }
|
||||||
|
@media (max-width:900px) { .compose-card,.voices-card,.controls-card,.activity-card { grid-column:span 6; }.hero { align-items:flex-start; flex-direction:column; }.sdk-badge { min-width:0; width:100%; } }
|
||||||
|
@media (max-width:640px) { .page { padding:20px 14px 28px; }.grid { grid-template-columns:1fr; gap:14px; }.compose-card,.voices-card,.controls-card,.activity-card { grid-column:span 1; }.card { padding:17px; }.filter-row { align-items:flex-start; flex-direction:column; gap:10px; }.textarea-meta { align-items:flex-start; flex-direction:column; gap:3px; } }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface LwaConfig {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
params: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback: LwaConfig = {
|
||||||
|
name: 'demo-speech-synthesis',
|
||||||
|
version: '0.1.1',
|
||||||
|
params: { title: 'CUTOS Speech Synthesis', subtitle: 'Web Speech API · Windows Offline TTS', host: 'localhost' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParams() {
|
||||||
|
const raw = new URLSearchParams(location.search).get('params')
|
||||||
|
if (!raw) return {}
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw)
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(): Promise<LwaConfig> {
|
||||||
|
try {
|
||||||
|
const url = new URL('config.json', window.location.href).toString()
|
||||||
|
const response = await fetch(url, { cache: 'no-cache' })
|
||||||
|
if (!response.ok) throw new Error(`Failed to load config.json: ${response.status}`)
|
||||||
|
const config = await response.json() as LwaConfig
|
||||||
|
return { ...fallback, ...config, params: { ...fallback.params, ...config.params, ...parseParams() } }
|
||||||
|
} catch {
|
||||||
|
return { ...fallback, params: { ...fallback.params, ...parseParams() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
publicDir: 'public',
|
||||||
|
build: { target: ['chrome74'] },
|
||||||
|
resolve: { alias: { '@': '/src' } },
|
||||||
|
plugins: [vue()],
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
define: { global: 'globalThis' },
|
||||||
|
target: 'es2015',
|
||||||
|
supported: { bigint: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user