昨天把六個維度都換算成同一把尺之後,心情輕鬆了不少,想說今天可以稍微喘口氣。
結果我打開整包 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(結束時間);
差別只在於參數怎麼調:
問題是,這幾個聲音沒有統一規格,每個函式裡面的寫法、參數命名都不太一樣,想共用一段邏輯根本無從下手。
所以今天的做法很單純:
先拆出一個「音效合成引擎」,所有聲音都透過它來產生,差異完全交給參數決定。
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() 的樣板程式碼。
今天做一個小小的「音效測試台」,把三種聲音獨立成按鈕,方便單獨試聽、單獨微調,不用每次都要重新玩一次測驗才能聽到求籤音:
<!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,我們要來處理多國語系:把所有文字抽成一份語言資料表,讓診所能夠即時切換語言,不用整頁重新整理或是點擊瀏覽器自帶的翻譯。
明天,讓診所開始學外語。我們明天見!