aardio 文档

aardio 范例: 仿真人语音合成

大声朗读 WebSocket 版

//仿真人语音合成
//大声朗读 WebSocket 版: https://www.aardio.com/zh-cn/doc/example/Media/Audio/edgeTTS.html

import chrome.edge; 
var theApp = chrome.edge.app();

import sys.audioVolume;
var volumeCtrl = sys.audioVolume();

import dotNet.waveIn;
theApp.external = {
    getVolume = function(){
        return volumeCtrl.volume;
    }
    setVolume = function(v){ 
        v = tonumber(v)
        if(volumeCtrl.volume == v) return;
        volumeCtrl.volume = v;
        volumeCtrl.mute = !v;
    }  
    startRecording = function(){
        dotNet.waveIn.startLoopback("/edge.wav");
    } 
    stopRecording = function(){
        dotNet.waveIn.stop();
    } 
    copyWav = function(){
        if(!io.exist("/edge.wav")){
            return theApp.msgboxErr("请先录音");
        }

        thread.invokeEx( 
            function(){
                import dotNet.waveIn;
                dotNet.waveIn.convertToMp3("/edge.wav","/edge.mp3");

                import win.clip.file;
                win.clip.file.write("/edge.mp3","copy") 
            }
        )

        return true;
    } 
}

theApp.httpHandler["/index.aardio"] = function(response,request){
    var html = /***
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Edge 语音合成</title>
    <script src="/aardio.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        :root {
            --primary: #6366F1;
            --primary-dark: #4F46E5;
            --success: #10B981;
            --danger: #EF4444;
            --bg: linear-gradient(135deg, #1e3a5f 0%, #2d1b4e 50%, #1a1a2e 100%);
            --card-bg: rgba(255,255,255,0.97);
            --text: #1E293B;
            --text-muted: #64748B;
            --border: #E2E8F0;
        }

        html, body {
            height: 100%;
            font-family: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
            background: var(--bg);
            color: var(--text);
        }

        .container {
            height: 100%;
            padding: 16px 24px;
            display: flex;
            flex-direction: column;
        }

        .card {
            background: var(--card-bg);
            border-radius: 16px;
            box-shadow: 0 20px 40px -12px rgba(0,0,0,0.25);
            padding: 20px 28px;
            flex: 1;
            display: flex;
            flex-direction: column;
            backdrop-filter: blur(10px);
            min-height: 0;
        }

        .main-layout {
            flex: 1;
            display: flex;
            gap: 24px;
            min-height: 0;
        }

        .left-panel {
            width: 320px;
            flex-shrink: 0;
            display: flex;
            flex-direction: column;
            gap: 14px;
        }

        .right-panel {
            flex: 1;
            display: flex;
            flex-direction: column;
            min-width: 0;
        }

        .section-title {
            font-size: 12px;
            font-weight: 600;
            color: var(--text-muted);
            text-transform: uppercase;
            letter-spacing: 0.5px;
            margin-bottom: 8px;
        }

        .voice-select-wrap select {
            width: 100%;
            padding: 10px 14px;
            border: 2px solid var(--border);
            border-radius: 10px;
            font-size: 13px;
            background: white;
            cursor: pointer;
            transition: all 0.2s;
        }

        .voice-select-wrap select:focus {
            outline: none;
            border-color: var(--primary);
            box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
        }

        .lang-indicator {
            display: inline-flex;
            align-items: center;
            gap: 4px;
            font-size: 11px;
            padding: 2px 8px;
            border-radius: 10px;
            background: #E0E7FF;
            color: #4338CA;
            margin-left: 8px;
            transition: all 0.3s;
        }

        .lang-indicator.en {
            background: #DCFCE7;
            color: #166534;
        }

        .sliders-section {
            background: #F8FAFC;
            border-radius: 12px;
            padding: 14px;
        }

        .slider-row {
            display: flex;
            align-items: center;
            gap: 12px;
            padding: 8px 0;
        }

        .slider-row:not(:last-child) {
            border-bottom: 1px solid #E2E8F0;
        }

        .slider-row .icon { font-size: 16px; width: 24px; text-align: center; }

        .slider-row .label {
            font-size: 13px;
            font-weight: 500;
            color: var(--text);
            width: 60px;
        }

        .slider-row input[type="range"] { flex: 1; }

        .slider-row .value {
            background: var(--primary);
            color: white;
            padding: 2px 10px;
            border-radius: 12px;
            font-size: 11px;
            font-weight: 700;
            min-width: 42px;
            text-align: center;
        }

        input[type="range"] {
            -webkit-appearance: none;
            height: 6px;
            border-radius: 3px;
            background: linear-gradient(90deg, var(--primary) 0%, #E2E8F0 0%);
            outline: none;
        }

        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 18px;
            height: 18px;
            border-radius: 50%;
            background: white;
            border: 3px solid var(--primary);
            cursor: pointer;
            box-shadow: 0 2px 6px rgba(99,102,241,0.3);
            transition: transform 0.15s;
        }

        input[type="range"]::-webkit-slider-thumb:hover {
            transform: scale(1.1);
        }

        .action-section {
            display: flex;
            gap: 12px;
            align-items: center;
            margin-top: auto;
            padding-top: 14px;
        }

        .btn-play {
            flex: 1;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            padding: 12px 20px;
            border: none;
            border-radius: 10px;
            font-size: 14px;
            font-weight: 700;
            cursor: pointer;
            background: linear-gradient(135deg, var(--primary), #8B5CF6);
            color: white;
            box-shadow: 0 6px 20px rgba(99,102,241,0.35);
            transition: all 0.25s;
        }

        .btn-play:hover:not(:disabled) {
            transform: translateY(-2px);
            box-shadow: 0 10px 24px rgba(99,102,241,0.45);
        }

        .btn-play:disabled { opacity: 0.7; cursor: not-allowed; }

        .btn-play.speaking {
            background: linear-gradient(135deg, var(--success), #059669);
            animation: pulse 1.2s ease-in-out infinite;
        }

        @keyframes pulse {
            0%, 100% { box-shadow: 0 6px 20px rgba(16,185,129,0.35); }
            50% { box-shadow: 0 6px 28px rgba(16,185,129,0.55); }
        }

        .btn-play .icon { font-size: 14px; }
        .btn-play.speaking .icon { animation: spin 1s linear infinite; }
        @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

        .recording-box {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 10px 14px;
            background: #FEF2F280;
            border: 2px solid #FECACA80;
            border-radius: 10px;
            cursor: pointer;
            transition: all 0.2s;
            font-size: 13px;
        }

        .recording-box:hover { background: #FEE2E2; }
        .recording-box.active { background: #FEE2E2; border-color: var(--danger); }
        .recording-box input { display: none; }

        .rec-dot {
            width: 10px;
            height: 10px;
            background: #FCA5A580;
            border-radius: 50%;
            transition: all 0.2s;
        }

        .recording-box.active .rec-dot {
            background: var(--danger);
            animation: blink 0.8s ease-in-out infinite;
        }

        @keyframes blink {
            0%, 100% { opacity: 1; transform: scale(1); }
            50% { opacity: 0.4; transform: scale(0.85); }
        }

        .recording-box span { font-weight: 500; color: #991B1B; }

        .file-link {
            color: var(--danger);
            text-decoration: none;
            font-weight: 600;
        }

        .file-link:hover { opacity: 0.7; }

        .textarea-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 10px;
        }

        .textarea-header .title {
            font-weight: 600;
            font-size: 14px;
            display: flex;
            align-items: center;
            gap: 6px;
        }

        .char-count {
            font-size: 12px;
            color: var(--text-muted);
        }

        #txt {
            flex: 1;
            width: 100%;
            padding: 16px;
            border: 2px solid var(--border);
            border-radius: 12px;
            font-size: 15px;
            line-height: 1.8;
            resize: none;
            transition: all 0.2s;
            font-family: inherit;
        }

        #txt:focus {
            outline: none;
            border-color: var(--primary);
            box-shadow: 0 0 0 4px rgba(99,102,241,0.1);
        }

        #txt::placeholder { color: #94A3B8; }

        .footer {
            text-align: center;
            padding-top: 12px;
            color: var(--text-muted);
            font-size: 11px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="card">
            <form id="inputForm" style="flex:1;display:flex;flex-direction:column;min-height:0;">
                <div class="main-layout">
                    <div class="left-panel">
                        <div class="voice-select-wrap">
                            <div class="section-title">🎤 选择语音 <span class="lang-indicator" id="langIndicator">中文</span></div>
                            <select id="voiceSelect"></select>
                        </div>

                        <div class="sliders-section">
                            <div class="section-title">⚙️ 参数设置</div>

                            <div class="slider-row">
                                <span class="icon">🔊</span>
                                <span class="label">音量</span>
                                <input type="range" min="0" max="100" step="1" id="volume">
                                <span class="value" id="volumeValue">50</span>
                            </div>

                            <div class="slider-row">
                                <span class="icon">⚡</span>
                                <span class="label">语速</span>
                                <input type="range" min="0.5" max="2" value="1" step="0.1" id="rate">
                                <span class="value" id="rateValue">1.0</span>
                            </div>

                            <div class="slider-row">
                                <span class="icon">🎵</span>
                                <span class="label">音调</span>
                                <input type="range" min="0" max="2" value="1" step="0.1" id="pitch">
                                <span class="value" id="pitchValue">1.0</span>
                            </div>
                        </div>

                        <div class="action-section">
                            <button type="submit" id="play" class="btn-play">
                                <span class="icon" id="playIcon">▶</span>
                                <span id="playText">朗读</span>
                            </button>

                            <label class="recording-box" id="recBox">
                                <input type="checkbox" id="recording">
                                <span class="rec-dot"></span>
                                <span>录音</span>
                            </label>
                        </div>

                        <button type="button"
                            onclick="aardio.copyWav().then(r=>{ this.innerText = ' ✓ 已复制录音文件为 edge.mp3';setTimeout(()=>this.innerText='📋 复制为 mp3',2500)} )" 
                            class="file-link" 
                            style="font-size:12px;text-align:center;border:none; background:none; cursor:pointer;">📋 复制为 mp3</button>
                    </div>

                    <div class="right-panel">
                        <div class="textarea-header">
                            <span class="title">📝 朗读文本</span>
                            <span class="char-count"><span id="charCount">0</span> 字</span>
                        </div>
                        <textarea id="txt" placeholder="请在此输入要朗读的文本内容..."></textarea>
                    </div>
                </div>
            </form>

            <div class="footer">高品质自然语音,支持多种中英文音色,自动识别语言</div>
        </div>
    </div>

    <script>
        const synth = speechSynthesis;
        const $ = id => document.getElementById(id);

        const inputForm = $('inputForm'), inputTxt = $('txt'), voiceSelect = $('voiceSelect');
        const playBtn = $('play'), playIcon = $('playIcon'), playText = $('playText');
        const volume = $('volume'), pitch = $('pitch'), rate = $('rate');
        const recBox = $('recBox'), recInput = $('recording');
        const langIndicator = $('langIndicator');

        let voices = [];
        let allVoices = [];
        let currentLang = 'zh';
        let detectTimer = null;

        function updateSliderBg(slider, value, min, max) {
            const pct = ((value - min) / (max - min)) * 100;
            slider.style.background = `linear-gradient(90deg, #6366F1 ${pct}%, #E2E8F0 ${pct}%)`;
        }

        // 检测文本主要语言
        function detectLanguage(text) {
            if (!text || !text.trim()) return 'zh';

            // 统计中文字符数量(包括中文标点)
            const chineseChars = (text.match(/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/g) || []).length;
            // 统计英文字符数量
            const englishChars = (text.match(/[a-zA-Z]/g) || []).length;

            // 如果没有任何有效字符,默认中文
            if (chineseChars === 0 && englishChars === 0) return 'zh';

            // 英文字符占比超过60%则认为是英文
            const total = chineseChars + englishChars;
            return (englishChars / total) > 0.6 ? 'en' : 'zh';
        }

        function populateVoiceList(lang = 'zh') {
            const keyword = lang === 'en' ? 'English' : 'Chinese';
            const defaultVoice = lang === 'en' ? 'Jenny' : 'Xiaoxiao';

            voices = allVoices.filter(v => v.name.includes(keyword))
                         .sort((a, b) => a.name.localeCompare(b.name));

            voiceSelect.innerHTML = '';

            let selectedIdx = 0;
            voices.forEach((v, i) => {
                const opt = document.createElement('option');
                opt.textContent = v.name.replace("Microsoft ", "").replace(" Online (Natural)", " ✨");
                opt.dataset.name = v.name;
                if (v.name.includes(defaultVoice)) selectedIdx = i;
                voiceSelect.appendChild(opt);
            });
            voiceSelect.selectedIndex = selectedIdx;

            // 更新语言指示器
            langIndicator.textContent = lang === 'en' ? 'English' : '中文';
            langIndicator.classList.toggle('en', lang === 'en');
        }

        function initVoices() {
            allVoices = synth.getVoices().filter(v => 
                v.name.includes("Chinese") || v.name.includes("English")
            );
            populateVoiceList(currentLang);
        }

        initVoices();
        if (synth.onvoiceschanged !== undefined) synth.onvoiceschanged = initVoices;

        function setPlayState(playing) {
            playBtn.disabled = playing;
            playBtn.classList.toggle('speaking', playing);
            playIcon.textContent = playing ? '◉' : '▶';
            playText.textContent = playing ? '朗读中' : '朗读';
        }

        function speak() {
            if (synth.speaking) return;

            const txt = inputTxt.value.trim() || "请先输入要朗读的文本";
            setPlayState(true);

            const utter = new SpeechSynthesisUtterance(txt);
            utter.onend = utter.onerror = () => { setPlayState(false); aardio.stopRecording(); };

            const selName = voiceSelect.selectedOptions[0]?.dataset.name;
            utter.voice = voices.find(v => v.name === selName) || null;
            utter.pitch = +pitch.value;
            utter.rate = +rate.value;
            synth.speak(utter);
        }

        inputForm.onsubmit = e => {
            e.preventDefault();
            recInput.checked ? aardio.startRecording().then(speak) : speak();
        };

        recBox.onclick = () => { recInput.checked = !recInput.checked; recBox.classList.toggle('active', recInput.checked); };

        volume.oninput = () => {
            $('volumeValue').textContent = volume.value;
            updateSliderBg(volume, volume.value, 0, 100);
            aardio.setVolume(volume.value);
        };

        rate.oninput = () => {
            $('rateValue').textContent = (+rate.value).toFixed(1);
            updateSliderBg(rate, rate.value, 0.5, 2);
        };

        pitch.oninput = () => {
            $('pitchValue').textContent = (+pitch.value).toFixed(1);
            updateSliderBg(pitch, pitch.value, 0, 2);
        };

        inputTxt.oninput = () => {
            $('charCount').textContent = inputTxt.value.length;

            // 防抖处理,避免频繁切换
            clearTimeout(detectTimer);
            detectTimer = setTimeout(() => {
                const detectedLang = detectLanguage(inputTxt.value);
                if (detectedLang !== currentLang) {
                    currentLang = detectedLang;
                    populateVoiceList(currentLang);
                }
            }, 500);
        };

        aardio.getVolume().then(v => { volume.value = v; volume.oninput(); });
        [rate, pitch].forEach(s => s.oninput());
    </script>
</body>
</html>
***/
    response.write(html)
}

theApp.indexReady(
    function($){
        theApp.doScript($,`
            document.getElementById("txt").value = "欢迎使用 Edge 语音合成工具!\n\n这是一段示例文本,支持多种自然中文语音。您可以调整语速和音调,还可以录制音频保存为 WAV 或 MP3 文件。";
            document.getElementById("txt").oninput();
        `)
    } 
)

theApp.setPos(20, 20, 1100, 520)
theApp.start("/res/index.aardio")

win.loopMessage();
Markdown 格式