feat: add speech synthesis demo

This commit is contained in:
yankun
2026-07-30 15:26:57 +08:00
parent cce0b95b7c
commit 6b0a0eede6
19 changed files with 438 additions and 0 deletions
@@ -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() } }
}
}
+1
View File
@@ -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 }
}
}
})