---
# System prepended metadata

title: 文字語音播報(Nuxt3)
tags: [實用功能]

---

# 文字語音播報(Nuxt3)
- 功能：使用JS可以將網頁的文字用瀏覽器的語音包播報。
<small>以下程式碼目前只有語音的播放及結束，尚未提供暫停功能</small>
- 注意事項：每個瀏覽器、行動裝置的語音包有所不同需注意！
```htmlembedded=
<template>
    <div>
        <p ref="articleContent">語音播報內容</p>
        <div class="speakBtnBox">
            <button class="speakBtn" @click="speak">
                <template v-if="isPlay">
                    <div class="stop"></div>
                </template>
                <template v-else>
                    <div class="play"></div>
                </template>
            </button>
        </div>
    </div>
</template>
<script setup>
const isPlay = ref(false) // 是否在播報中
const articleContent = ref(null)
// 確認語音包已被加載
const loadVoices = () => {
    if (window.speechSynthesis) {
        window.speechSynthesis.resume(); // 確保音頻播放未被暫停
    }
    return new Promise((resolve) => {
        let voices = window.speechSynthesis.getVoices();
        if (voices.length !== 0) {
            resolve(voices);
        } else {
            window.speechSynthesis.onvoiceschanged = () => {
                voices = window.speechSynthesis.getVoices();
                resolve(voices);
            };
        }
    });
};
    
// 處理過長文本
const splitTextByLength = (text, maxLength = 200) => {
    const sentences = text.match(/[^。！？.!?]*[。！？.!?]/g) || [text];
    const chunks = [];
    for (let sentence of sentences) {
        while (sentence.length > maxLength) {
            chunks.push(sentence.slice(0, maxLength));
            sentence = sentence.slice(maxLength);
        }
        if (sentence) chunks.push(sentence);
    }
    return chunks;
};
    
// 播放段落的語音
const playChunk = (chunk, selectedVoice, retries = 0, maxRetries = 3) => {
    return new Promise((resolve, reject) => {
        window.speechSynthesis.cancel();
        const utterance = new SpeechSynthesisUtterance();
        utterance.voice = selectedVoice;
        utterance.lang = selectedVoice.lang;
        utterance.text = chunk;
        utterance.volume = 1;
        utterance.pitch = 1
        utterance.rate = 1

        utterance.onstart = async () => {
            console.log("段落播放開始: ", chunk);
            // 避免螢幕進入省電模式中斷語音
            if (!wakeLock && 'wakeLock' in navigator) {
                try {
                    wakeLock = await navigator.wakeLock.request('screen');
                    console.log("Wake lock acquired");
                } catch (err) {
                    console.error("Failed to acquire wake lock", err);
                }
            }
        };
        
        utterance.onend = () => {
            console.log("段落播放完成");
            resolve(); // 當播放完成時，繼續下一段
        };

        utterance.onerror = (event) => {
            if(event.error !== 'interrupted'){
                console.error("onerror: ", event.error);
                if (retries < maxRetries) {
                    console.log(`重試第 ${retries + 1} 次...`);
                    resolve(playChunk(chunk, utterance, retries + 1, maxRetries)); // 重試失敗的段落
                } else {
                    reject(new Error("播放失敗，已達最大重試次數"));
                }
            }
        };

        window.speechSynthesis.speak(utterance);
    });
};

    
let wakeLock = null
// 釋放 wakeLock
const unmountWakeLock = async() =>{
    if (wakeLock) {
        try {
            await wakeLock.release();
            wakeLock = null;
            console.log("Wake lock released");
        } catch (error) {
            console.error("Failed to release wake lock", error);
        }
    }
}

// 設置播放狀態
const setPlayState = (state) => {
    isPlay.value = state;
    if (!state) {
        window.speechSynthesis.cancel(); // 停止播放
        unmountWakeLock()
    }
};


// 點擊播放或結束
const speak = async() =>{
    window.speechSynthesis.cancel();
    
    // 提前調用 window.speechSynthesis.getVoices() 獲取可用語音
    const voices = await loadVoices();
    
    // 如果正在播放，則停止播放
    if(isPlay.value){
        setPlayState(false)
        return; // 退出，防止重複播放
    } else {
        if (!article_text_block?.value) {
            console.error("無法捕捉到文本標籤");
            return;
        }
        
        let readContent = article_text_block.value.innerText;
        if (!readContent) {
            console.error("無法獲取內文");
            return;
        }
        
        if(voices){
            // 設置語音包
            const priorityVoices = [
                { voiceURI: '美佳' },
                { voiceURI: 'Microsoft Yating - Chinese (Traditional, Taiwan)' },
            ];
            
            const selectedVoice =
                priorityVoices
                    .map((pv) => voices.find((v) => v.voiceURI === pv.voiceURI || v.name === pv.voiceURI))
                    .find((v) => v !== undefined)
                || voices.find((v) => /zh[-_]?tw/i.test(v.lang));
            
            if (!selectedVoice) {
                alert("您的裝置不支援此播放功能");
                return;
            }
            
            // 開始播放
            setPlayState(true)
            const chunks = splitTextByLength(readContent); // 文本分段
            
            try {
                const startTime = Date.now(); // 記錄整篇文章開始播放的時間
                for (const chunk of chunks) {
                    if (!isPlay.value) break; // 中止播放
                    await playChunk(chunk, selectedVoice);
                }
                const elapsedTime = (Date.now() - startTime) / 1000; // 計算整篇文章播放耗時（秒）
                if (elapsedTime < 10) { // 如果整篇文章播放時間小於 10 秒
                    alert("播放時間過短，可能語音包異常，建議更新文字轉語音的語音包。");
                }
            } catch (error) {
                console.error("語音播放失敗：", error);
            } finally {
                setPlayState(false)
            }
        }
    }
}

// window 系統 onBeforeUnmount 重整頁面時無法停止播放故在完成渲染時先執行一次停止播放
onMounted(() =>{
    window.speechSynthesis.cancel();
    // 釋放 wakeLock
    unmountWakeLock()
})
    
// 離開當前頁面時停止播放
onBeforeUnmount(() => {
    window.speechSynthesis.cancel();
    // 釋放 wakeLock
    unmountWakeLock()
})
</script>
<style lang="scss" scoped>
$white: #fff;
$Blue: #177BA7;
.speakBtnBox{
    display: flex;
    justify-content: flex-end;
    .speakBtn{
        width: 50px;
        height: 50px;
        border-radius: 50%;
        border: none;
        background: $Blue;
        color: $white;
        display: flex;
        justify-content: center;
        align-items: center;
        @media screen and (max-width: 960px){
            width: 40px;
            height: 40px;
        }
        .stop{
            width: 12px;
            height: 12px;
            background: $white;
            @media screen and (max-width: 960px){
                width: 10px;
                height: 10px;
            }
        }
        .play{
            border-top: 10px solid transparent;
            border-bottom: 10px solid transparent;
            border-left: 15px solid $white;
            border-right: 15px solid transparent;
            transform: translate(10px);
            @media screen and (max-width: 960px){
                border-top: 8px solid transparent;
                border-bottom: 8px solid transparent;
                border-left: 12px solid $white;
                border-right: 12px solid transparent;
                transform: translate(7.5px);
            }
        }
    }
}
</style>
```

### 參考資料
[js純前端實現語音播報，朗讀功能](https://blog.csdn.net/hap1994/article/details/137788111)

### 筆記更新
#### 2024/11/28更新筆記
- 添加判定當未匹配到預設的語音包時，從voices取得語音包中第一筆lang包含了zh_tw 或 zh-tw (忽略大小寫)的語音包，修正Android系統語音未取得bug
- 文本改為分段播放，修正Android系統文本過長時無法正確播放語音bug
- 添加wakeLock，避免手機進入省電模式關閉螢幕導致語音中斷
(需注意添加時機，錯誤的時間添加在iphone可能會造成語音無法正確播放)

<small>備註：</small>
<small style="padding-left: 2em;">1.確認行動裝備 ios系統、Android系統：小米手機、Sony手機 可正常運行</small>
<small style="display:block; padding-left: 2em;">2.Samsung手機在手機的語言包未更新的時，可能會出現speechSynthesis.speak在短暫時間內直接將文本結束，導致語音無法正常使用</small>
------------------------------------------------------------------
<small>**持續更新中！**</small>
<small>**以上為自學中的筆記如有錯誤，懇請留言指正，感謝！**</small>