早在十幾年前我就知道用 <audio> 可以在網頁播 MP3,當時還寫了在 iPad 跑的英文單字練習互動網頁搶救小孩的菜英文,而我對瀏覽器的音效能力了解也一直停在這裡。

註:很遺憾,雖然後來我還搞了 單字朗讀 MP3、單字卡、高中 7000 單線上測試... 小閃光跟小木頭的英文終究都沒被救起來了,科技不敵命運的鎖鏈,無法扭轉乾坤 Orz

最近意外發現,瀏覽器能做的不只於此,現代瀏覽器都支援 Web Audio API,能模擬振盪器(Oscillator)生成指定頻率波形(弦波、方波...),並可混合多個音訊、串接濾波器發出聲音,搞出神奇的花式玩法。有玩過任天堂紅白機或 AdLib 音效卡 的老人對這種合成技術與電子音樂應不陌生,現在寫幾行 JavaScript 便能實現。

Web Audio API 以音訊節點串接音訊路由的概念生成及操控音訊。音訊節點透過其輸入與輸出連結成鏈狀或簡單的網狀結構,將數學計算模擬的振盪波、音樂/影片檔錄音,或是音訊串流連結到一個或多個效果節點(例如:殘響、雙二階濾波器、平移器、壓縮器),最後串接到最終目的地(耳機、喇叭等輸出)。有趣的是,它甚至有個 AnalyserNode 可將音波轉為 byte[],配合 Canvas 繪圖可模擬示波器顯示,觀察聲波形狀。

Web Audio API 涉及不少物理學與術語,想深入應用需要一定的專業知識背景,有興趣深入的同學,推薦 OXXO.STUDIO 有一系列好文,比 MDN 文件易讀好懂:

總之,我寫了簡單振盪器發聲練習熟悉一下 Audio API 的用法:線上展示 原始碼

一不做二不休,再寫段小程式模擬傳統電話撥話過程,很有趣吧?

效果展示

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>電話撥號模擬</title>
</head>

<body>
    <button id="play" type="button" style="font-size: 2em; margin: 12px; padding: 12px 24px">模擬電話撥號</button>
    <script>
        const AudioContextClass = window.AudioContext || window.webkitAudioContext;
        const DTMF = {
            0: [941, 1336], 1: [697, 1209], 2: [697, 1336], 3: [697, 1477], 4: [770, 1209],
            5: [770, 1336], 6: [770, 1477], 7: [852, 1209], 8: [852, 1336], 9: [852, 1477]
        };
        const phoneNumber = "28825252";
        const playButton = document.querySelector("#play");
        let audioContext;
        let masterGain;
        function createTones(frequencies, start, duration, volume = 0.08, type = "sine", endFrequency) {
            for (const frequency of frequencies) createTone(frequency, start, duration, volume, type, endFrequency);
        }
        function createTone(frequency, start, duration, volume = 0.08, type = "sine", endFrequency) {
            // OscillatorNode 用來產生指定頻率的週期波形,GainNode 負責控制音量
            // 每次建立獨立節點,以便多個頻率可在同一時間播放,例如 DTMF 的雙音組合。
            const oscillator = audioContext.createOscillator();
            const gain = audioContext.createGain();

            // 設定波形種類與起始頻率,start 是 AudioContext 的絕對時間
            // 確保不會因 JavaScript 執行延遲,影響開始播放時機
            oscillator.type = type;
            oscillator.frequency.setValueAtTime(frequency, start);

            // 若有提供結束頻率,會音調持續期間做指數式滑音,接近人耳對音高的感受
            if (endFrequency) oscillator.frequency.exponentialRampToValueAtTime(endFrequency, start + duration);

            // 建立簡短的音量包絡(Envelope):從靜音淡入、維持指定音量,結尾再淡出
            // 避免波形在非零振幅處突然開始或停止產生喀聲(click/pop)
            gain.gain.setValueAtTime(0, start);
            // 淡入最多 15 毫秒;短音調則使用其長度的四分之一,以免淡入占比過高
            gain.gain.linearRampToValueAtTime(volume, start + Math.min(0.015, duration / 4));
            // 在結束前至少保留 25 毫秒淡出空間,加入 Math.max 因應極短音調
            gain.gain.setValueAtTime(volume, start + Math.max(0.015, duration - 0.025));
            gain.gain.linearRampToValueAtTime(0, start + duration);

            // 將「振盪器 -> 單音增益 -> 主增益 -> 喇叭」串接起來。
            // 主增益可統一調整總音量
            oscillator.connect(gain).connect(masterGain);

            // 啟動與停止也使用 AudioContext 時間排程,多留 10 毫秒再銷毀節點,確保淡出自動化完整執行
            // 停止後節點會由瀏覽器回收
            oscillator.start(start);
            oscillator.stop(start + duration + 0.01);
        }

        function sayHello(start = audioContext.currentTime) {
            const duration = 0.45;
            const voiceGain = audioContext.createGain();
            // 建立 Envelope:淡入、維持、淡出,避免喀聲
            voiceGain.gain.setValueAtTime(0, start);
            voiceGain.gain.linearRampToValueAtTime(0.2, start + 0.04);
            voiceGain.gain.setValueAtTime(0.2, start + duration - 0.08);
            voiceGain.gain.linearRampToValueAtTime(0, start + duration);
            voiceGain.connect(masterGain);

            const vocalSource = audioContext.createOscillator();
            const sourceGain = audioContext.createGain();
            vocalSource.type = "sawtooth";

            // 設定起始頻率,並在持續期間做滑音,模擬人聲的音高變化
            vocalSource.frequency.setValueAtTime(145, start);
            vocalSource.frequency.linearRampToValueAtTime(130, start + duration * 0.65);
            vocalSource.frequency.exponentialRampToValueAtTime(170, start + duration);
            sourceGain.gain.value = 0.25;
            vocalSource.connect(sourceGain);

            let formantOutput = sourceGain;

            function createFormant(initialFrequency, endFrequency, gainDb, quality) {
                // 用 BiquadFilterNode 模擬人聲的共振峰 (Formant)
                const filter = audioContext.createBiquadFilter();
                filter.type = "peaking";
                filter.frequency.setValueAtTime(initialFrequency, start);
                filter.frequency.linearRampToValueAtTime(endFrequency, start + duration);
                // 品質因數 (Q),數值越高共振峰越尖銳,數值越低共振峰越寬廣
                filter.Q.value = quality;
                filter.gain.value = gainDb;
                formantOutput.connect(filter);
                formantOutput = filter;
            }

            // 加入三段共振峰,模擬人聲的主要共振頻率
            createFormant(300, 430, 10, 2.5);
            createFormant(650, 1900, 25, 3.5);
            createFormant(2000, 2250, 45, 3);
            formantOutput.connect(voiceGain);

            vocalSource.start(start);
            vocalSource.stop(start + duration + 0.01);
            vocalSource.onended = () => {
                playButton.disabled = false;
                voiceGain.disconnect();
            };
        }

        function scheduleSequence() {
            const start = audioContext.currentTime + 0.08;
            // 撥話音:350hz + 440hz
            createTones([350, 440], start, 2, 0.055);
            let cursor = start + 2.05;
            for (const digit of phoneNumber) {
                createTones(DTMF[digit], cursor, 0.12, 0.1);
                cursor += 0.2;
            }
            // 鈴聲:480hz + 440hz
            cursor += 0.5;
            for (let i = 0; i < 2; i += 1) {
                createTones([480, 440], cursor, 1, 0.055);
                cursor += 2.5
            }
            // 模擬「喂」
            sayHello(cursor);
        }

        async function playSequence() {
            if (!AudioContextClass) return;
            playButton.disabled = true;
            audioContext ||= new AudioContextClass();
            await audioContext.resume();
            masterGain = audioContext.createGain();
            masterGain.gain.value = 0.75;
            masterGain.connect(audioContext.destination);
            scheduleSequence();
        }

        playButton.addEventListener("click", playSequence);
    </script>
</body>

</html>

Exploring the Web Audio API beyond HTML audio, including oscillators, filters, audio routing, visualization with AnalyserNode, and JavaScript-generated sounds. Includes a simple oscillator demo and a fun simulation of traditional telephone dialing tones.


Comments

Be the first to post a comment

Post a comment