iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 18 篇

Day 18:聲音多到打架,是時候幫它們分房間了

  • 分享至 

  • xImage
  •  

前言

昨天把六個維度都換算成同一把尺之後,心情輕鬆了不少,想說今天可以稍微喘口氣。

結果我打開整包 JavaScript,想隨手調一下求籤的音效,想讓抽到「大凶」的時候聲音悶一點、更有那種晴天霹靂的感覺。

然後我在檔案裡翻了快五分鐘。

點擊音、籤筒音、泡泡破裂音,全部長得有點像,變數名字也都差不多,playClick、playPop、playSound……找到最後我自己都搞不清楚哪個是哪個。改到一半甚至不小心把點擊音的參數改壞,結果變成每次點按鈕都像在按壞掉的門鈴。

這讓我想起國小的時候,所有文具都塞在同一個鉛筆盒,要用的時候永遠翻半天。

不是東西不夠用,是沒有分類。

所以今天要

把診所裡所有的聲音,正式分房間住。


核心觀念:同一套合成邏輯,換個參數就是不同的聲音

這幾天陸續寫下來的音效,其實骨架幾乎一模一樣,都是靠 Web Audio API 的這三樣東西合成出來的:

const osc = ctx.createOscillator();  // 負責「發出什麼音高」
const gain = ctx.createGain();       // 負責「音量怎麼變化」

連起來,設定幾個參數,然後播放:

osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(結束時間);

差別只在於參數怎麼調:

  • 點擊音:高音開始、很快往下掉、時間很短(0.08 秒),聽起來清脆
  • 破裂音:比點擊音稍微長一點、音量變化更劇烈,聽起來有「啵」的感覺
  • 求籤音:音高一路爬升、拉得比較長,聽起來像在醞釀期待感

問題是,這幾個聲音沒有統一規格,每個函式裡面的寫法、參數命名都不太一樣,想共用一段邏輯根本無從下手。

所以今天的做法很單純:

先拆出一個「音效合成引擎」,所有聲音都透過它來產生,差異完全交給參數決定。

function playTone({ type, startFreq, endFreq, duration, volume }) {
    const ctx = getAudio();
    const t = ctx.currentTime;

    const osc = ctx.createOscillator();
    const gain = ctx.createGain();

    osc.type = type;
    osc.frequency.setValueAtTime(startFreq, t);
    osc.frequency.exponentialRampToValueAtTime(endFreq, t + duration);

    gain.gain.setValueAtTime(volume, t);
    gain.gain.exponentialRampToValueAtTime(0.001, t + duration);

    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.start(t);
    osc.stop(t + duration);
}

有了這個引擎之後,每一種音效就只剩下「呼叫它、丟自己的參數」:

function playClickSound() {
    playTone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });
}

function playPopSound() {
    playTone({ type: "sine", startFreq: 500, endFreq: 150, duration: 0.15, volume: 0.2 });
}

function playDrawSound() {
    playTone({ type: "triangle", startFreq: 300, endFreq: 700, duration: 0.3, volume: 0.18 });
}

三行解決一種聲音。想調音色,改數字就好,不用再去動那一大串 createOscillator() 的樣板程式碼。


動手寫 Code:音效控制台

今天做一個小小的「音效測試台」,把三種聲音獨立成按鈕,方便單獨試聽、單獨微調,不用每次都要重新玩一次測驗才能聽到求籤音:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:音效分離架構</title>
<style>
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #e3f2fd;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        padding: 20px;
    }
    .card {
        background: white;
        padding: 30px;
        border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.15);
        width: 100%;
        max-width: 460px;
        text-align: center;
    }
    h2 { color: #1e88e5; margin: 0 0 5px; }
    .subtitle { color: #546e7a; font-size: 0.9rem; margin-bottom: 20px; }

    .sound-group {
        background: #fdfdfd;
        border: 2px dashed #90caf9;
        border-radius: 14px;
        padding: 16px;
        margin-bottom: 14px;
        text-align: left;
    }
    .sound-group h3 {
        margin: 0 0 4px;
        font-size: 1rem;
        color: #263238;
    }
    .sound-group .desc {
        font-size: 0.8rem;
        color: #546e7a;
        margin-bottom: 10px;
    }

    .btn {
        background-color: #1e88e5;
        color: white;
        border: none;
        border-radius: 50px;
        padding: 10px 24px;
        font-size: 0.9rem;
        cursor: pointer;
        transition: 0.2s;
    }
    .btn:hover { background-color: #1565c0; transform: translateY(-2px); }
    .btn:active { transform: scale(0.95); }

    .log {
        text-align: left;
        background: #e3f2fd;
        border-radius: 12px;
        padding: 10px 14px;
        font-size: 0.8rem;
        color: #1e88e5;
        max-height: 100px;
        overflow-y: auto;
        margin-top: 10px;
    }
</style>
</head>
<body>

    <div class="card">
        <h2>🎛️ 音效分離控制台</h2>
        <p class="subtitle">每一種聲音,現在都有自己的函式</p>

        <div class="sound-group">
            <h3>👆 點擊音</h3>
            <div class="desc">清脆、短促,用在按鈕與頭像選擇</div>
            <button class="btn" onclick="playClickSound()">播放 playClickSound()</button>
        </div>

        <div class="sound-group">
            <h3>💥 破裂音</h3>
            <div class="desc">稍微拖長一點,用在戳破壓力泡泡</div>
            <button class="btn" onclick="playPopSound()">播放 playPopSound()</button>
        </div>

        <div class="sound-group">
            <h3>🎲 求籤音</h3>
            <div class="desc">音高往上爬,製造期待感,用在搖籤筒</div>
            <button class="btn" onclick="playDrawSound()">播放 playDrawSound()</button>
        </div>

        <div class="log" id="log">點按鈕聽聽看差異吧</div>
    </div>

    <script>
        let audioCtx = null;

        function getAudio() {
            if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            if (audioCtx.state === "suspended") audioCtx.resume();
            return audioCtx;
        }

        // ---------- 今天的重點:共用的合成引擎 ----------
        // 所有聲音都從這裡產生,差異只在傳進來的參數
        function playTone({ type, startFreq, endFreq, duration, volume }) {
            try {
                const ctx = getAudio();
                const t = ctx.currentTime;

                const osc = ctx.createOscillator();
                const gain = ctx.createGain();

                osc.type = type;
                osc.frequency.setValueAtTime(startFreq, t);
                osc.frequency.exponentialRampToValueAtTime(endFreq, t + duration);

                gain.gain.setValueAtTime(volume, t);
                gain.gain.exponentialRampToValueAtTime(0.001, t + duration);

                osc.connect(gain);
                gain.connect(ctx.destination);
                osc.start(t);
                osc.stop(t + duration);

                log(`${type} ${startFreq}Hz → ${endFreq}Hz,${duration}s`);
            } catch (e) {
                log("播放失敗:" + e.message);
            }
        }

        // ---------- 三種音效,各自只負責自己的參數 ----------
        function playClickSound() {
            playTone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });
        }

        function playPopSound() {
            playTone({ type: "sine", startFreq: 500, endFreq: 150, duration: 0.15, volume: 0.2 });
        }

        function playDrawSound() {
            playTone({ type: "triangle", startFreq: 300, endFreq: 700, duration: 0.3, volume: 0.18 });
        }

        // ---------- 簡單記錄每次播放的參數,方便比對 ----------
        function log(text) {
            const el = document.getElementById("log");
            const time = new Date().toLocaleTimeString("zh-TW", { hour12: false });
            el.innerHTML = `[${time}] ${text}<br>` + el.innerHTML;
        }
    </script>
</body>
</html>

我存成 .html 打開,三個按鈕輪流點幾次,耳朵會很明顯聽出差異,而且下面的紀錄欄會告訴你剛剛那個聲音是用什麼參數合成出來的,等於是順便做了一個「音效參數對照表」。


幾個我踩過的小細節

1. 為什麼用物件參數 { type, startFreq, ... },不直接傳一串參數?

一開始我寫的是 playTone(type, startFreq, endFreq, duration, volume),結果呼叫的時候完全記不住順序,常常把 duration 跟 volume 填反,而且還不會報錯,只會悄悄發出很奇怪的聲音。

改成物件之後,呼叫時每個參數都寫名字,順序隨便排都沒差,半年後的自己應該也看得懂:

playTone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });

2. 三種聲音真的只是「同一個函式換參數」而已嗎?

拆完之後我才發現,對,真的就是這樣。之前我以為每種聲音背後有不太一樣的邏輯,結果仔細比對,骨架完全相同,只是:

  • 音高從哪裡滑到哪裡
  • 滑多久
  • 一開始多大聲

三個數字決定了整個聽覺印象。這讓我有點意外,原來「聽起來完全不同的音效」,背後其實可以共用同一套邏輯,差別只在於調的是哪幾個旋鈕。

3. 為什麼每個音效函式還是保留獨立命名,而不是全部用 playTone() 加參數就好?

因為程式的其他地方(按鈕的 onclick、泡泡被戳破的事件)都是在講「使用者情境」,不是在講「音高參數」。

btn.onclick = () => playPopSound() 一看就知道是點了什麼就會發生什麼事;如果改成 btn.onclick = () => playTone({ type: "sine", ... }),呼叫的地方反而要去記一堆數字,可讀性整個變差。

所以今天拆出來的其實是兩層:playTone() 負責「怎麼合成」,playClickSound() 這些函式負責「什麼情境用什麼參數」,各管各的事。

4. try...catch 包住整個合成過程,是不是太小心?

這個我是從之前做壓力泡泡那篇學到的教訓。音效環境在某些瀏覽器設定下可能直接用不了,與其讓整段 JavaScript 當機、把後面要執行的程式全部卡住,不如讓它失敗得安安靜靜,畫面照常運作,只是少了聲音而已。診所可以沒有聲音,但不能整個當掉。


開發心得與設計考量

今天這篇寫起來,其實比想像中花更多時間,但不是花在「寫程式」,是花在「拆」。

一開始我想說,把三個函式複製貼上到同一個檔案裡不就好了,反正功能都還在。但真的動手整理才發現,複製貼上只是把亂堆在一起的東西,換個地方繼續亂堆。真正有幫助的是把「共同的部分」抽出來,讓差異只留在真正不一樣的地方。

這也讓我想到前面做六維度歸類的時候,那個換算百分比的公式,也是同一種感覺:先看出表面上不一樣的幾件事,骨子裡其實是同一套邏輯。

重構這件事,很多時候不是在增加新功能,而是在找出哪些東西本來就該長得一樣,卻因為是不同時間寫的,變成了不一樣的樣子。

還有一個小小的副作用:因為現在所有音效都走同一個 playTone(),如果以後想要全域「靜音模式」,只要在這一個函式最前面加個判斷就好,完全不用一個一個去改三支音效函式。這個是我拆完之後才意外發現的福利,寫之前壓根沒想到。


今日結語與明日預告

今天我們把原本散落各處、長得差不多卻各自為政的音效函式,整理成一套共用的合成引擎,點擊音、破裂音、求籤音,現在都只是丟不同參數進同一個 playTone() 而已,找聲音、調聲音都不用再大海撈針。

聲音的部分整理乾淨了,還有另一個得狠狠修改的地方呢! 那就是 診所現在只有中文。

如果今天有朋友想分享給不是說中文的人呢?整個介面、題目、按鈕文字全部寫死在 HTML 跟 JavaScript 裡,想加一種語言,等於要把所有文字再抄一份,光用想的就覺得很痛苦。

所以明天 Day 19,我們要來處理多國語系:把所有文字抽成一份語言資料表,讓診所能夠即時切換語言,不用整頁重新整理或是點擊瀏覽器自帶的翻譯。

明天,讓診所開始學外語。我們明天見!


上一篇
Day 17:每個維度都要站在同一把尺上
下一篇
Day 19:多國語系支援(i18n):實作中、英、韓、日四語即時動態切換
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言