Call For Music

實踐總是最困難

最初的最初

2015的時候玩到一個Google所製作的小遊戲「光劍出鞘」,揮舞手機即可使用螢幕中的光劍來阻擋子彈。

同時,因為接觸日本偶像文化,得知了一點點打Call的知識,覺得若是可以將其結合至音樂遊戲一定很有趣。

核心技術(們) Sensor API

現今的行動裝置中幾乎都含有加速度計、陀螺儀和羅盤,因此在現今的網頁技術中也提供了獲取這些資訊的方法,雖然更完善與更進階的規範還在討論中,但已可使用部分的功能了。

透過監聽devicemotion事件,可以以一定的頻率獲得當前三維的加速度值以及角速度,而透過計算加速度的瞬時變化量則可以知道使用者揮動手機的方式。

window.addEventListener('devicemotion', (e)=>{
    console.log(e.acceleration);
    console.log(e.accelerationIncludingGravity);
    console.log(e.rotationRate);
    console.log(e.interval);
    // e.acceleration.x
});

核心技術(們) Sensor API

為了準確解析出使用者的動作,我自己揮舞手機收集了許多的資料,透過SVM學習後卻得到了不甚理想的結果,最後只好僅用Rule-Base的方式去判定手機的揮動,因此遊戲目前最多只能有一維的動作變化。

if (window.DeviceMotionEvent) {
    const threshold = 20;
    var pre_x, pre_y, pre_z, pre_t = Date.now();
    window.addEventListener('devicemotion', function(e) {
        var data = e.accelerationIncludingGravity;
        if (Math.abs(data.x - pre_x) > threshold || 
            Math.abs(data.y - pre_y) > threshold || 
            Math.abs(data.z - pre_z) > threshold) {
            var t = Date.now();
            if (t - pre_t > 100) socket.emit('beat', { time: t });
            pre_t = t;
        }
        pre_x = data.x, pre_y = data.y, pre_z = data.z;
    }, false);
} else {
    alert('Not Supported Browser');
}

核心技術(們) Audio API

在HTML5中多出了許多媒體元件,而除了操作播放的媒體外,我們還可以有更多進階的操作,如聲道的拆解與合成、創造聲音並合併等。

在這些操作中,我們是將音樂檔案透過AudioContext導入後,連接各種內建的節點來達成。

核心技術(們) Audio API

在本遊戲中最重要的一種節點是AnalyserNode,他可以透過FFT及時獲取一段聲音在時域或頻域的資料,這讓我們除了可以漂亮的將頻譜圖繪製在網頁上外,還可以透過分析頻域的變化來判定是否要生成一個note給使用者。

不過在判斷是否生成note上,其實仍有許多的困難。同樣的原先我打算使用機器學習來完成這項難事,卻發現feature不知道該怎麼抽。此外,每台電腦的效能不同,也不一定能及時處理資料。

核心技術(們) WebSocket

雖然有了HTTP協議可以讓我們跟伺服器連線並且交換資料,但是一切都只能由客戶端發起,而且每次發起都要與伺服器handshake,隨著網路速度的加快,對於許多即時通訊的需求已無法處理。

因而誕生的WebSocket則解決了這些問題,透過一個長期的連線,伺服器與客戶端可以對等的發送任何訊息,中間不需要再經過其他繁瑣的過程。

核心技術(們) WebSocket

不過我並沒有直接使用JavaScript中原生的WebSocket API,而是使用封裝好的socket.io作為前後端處理即時通訊的方法,因為socket.io大幅簡化了許多的操作,也提供了像是room等功能,可以簡單的指定要像那些人發送訊息。於是我就可以將手機晃動的資訊即時傳送給網頁端,來達到beat的效果。

/* server-side */
io.on('connection', (socket) => {
    const roomname = socket.request.user.username;
    const md = new MobileDetect(socket.request.headers['user-agent']);
    if (typeof roomdata[roomname] === 'undefined') {
        roomdata[roomname] = { devices: { mobile: false, computer: false } };
    }
    socket.join(roomname, () => {
        let devices = roomdata[roomname].devices;
        if (md.mobile()) devices.mobile = true;
        else devices.computer = true;
        io.to(roomname).emit('devices', devices);
        roomdata[roomname].devices = devices;
    });
    socket.on('beat', (data) => {
        io.to(roomname).emit('beat');
    });
});

曾經的核心技術 LibSVM

Supported Vector Machine(SVM中文譯作支援向量機),是一種專門拿來分類的演算法,他會計算並找出margin最大的一個超平面將兩種資料分開。

同時,有時候在低維的空間中可能並不能找到合適的超平面分割,所以透過映射函數將資料映射到更高維度的空間中,這種技巧稱之為Kernel

曾經的核心技術 LibSVM

而若是資料不可分割的話,則會透過cost function的設置,來尋求最佳的超平面,而這也通常會被拿來調整以避免overfit的情況。

若有多種類的情況,則會透過建立二元樹來將所有東西分出來。

檔案架構

透過編輯器(VS Code)的套件,我在每次存檔後,會利用ES Lint做靜態的語法分析

模板引擎的部分,我選擇使用較易入手的EJS,他可以無縫與HTML配合,透過許多tag的加入,如<-, <=, <%等,讓我們可以執行Javascript或是輸出從伺服器端提供的資料。

{
    "env": {
        "browser": true,
        "commonjs": true,
        "node": true,
        "jquery": true
    },
    "extends": "eslint:recommended",
    "parserOptions": {
        "sourceType": "module",
        "ecmaVersion": 2017
    },
    "rules": {
        "indent": [
            "error",
            4,
            {
                "SwitchCase": 1
            }
        ],
        "linebreak-style": [
            "error",
            "unix"
        ],
        "quotes": [
            "error",
            "single"
        ],
        "semi": [
            "error",
            "always"
        ],
        "no-console": "off"
    }
}

前端UI框架

我採用目前網頁世界中氾濫的Bootstrap,因為他的輕量(比起Semantic之類的),與高度語意化的class結構,讓開發者能夠迅速的開發出美觀的網站

後端帳號管理

我使用passport套件,並且搭配passport-local跟passport-local-mongoose套件將所有資料存在本地中的資料庫。

const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');

mongoose.Promise = require('bluebird');
const MonConn = mongoose.createConnection('mongodb://localhost:27017/CallForMusic');
const Schema = mongoose.Schema;

const AccountSchema = new Schema({
    username: String,
    introduction: String,
    icon: String
});

AccountSchema.plugin(passportLocalMongoose, {
    errorMessages: {
        UserExistsError: '此帳號已被使用',
        IncorrectPasswordError: '帳號或密碼錯誤',
        IncorrectUsernameError: '帳號或密碼錯誤',
        MissingUsernameError: '請輸入帳號',
        MissingPasswordError: '請輸入密碼',
        AttemptTooSoonError: '帳號目前無法登入,請稍後再試',
        TooManyAttemptError: '帳號目前無法登入,請勿大量嘗試各式密碼'
    }
});
let User = MonConn.model('account', AccountSchema);

無比簡單的vue應用

我使用passport套件,並且搭配passport-local跟passport-local-mongoose套件將所有資料存在本地中的資料庫。

<div id="plot">
    <div style="height: 550px;">
        <div class='cool' v-for="data in time_domain" v-bind:style="{ height: `${data * 2}px` }"></div>
    </div>
</div>
<script>
    let vm = new Vue({
        el: '#plot',
        data: {
            time_domain: new Uint8Array(FFT_SIZE)
        }
    });
    function process_fft_data(timer) {
        analyser.getByteTimeDomainData(vm.time_domain);
        vm.$forceUpdate();
        requestAnimationFrame(process_fft_data);
    }
    requestAnimationFrame(process_fft_data);
</script>

紀錄遊戲

透過內建的Crypto API,我們可以直接計算hash值,讓使用者上傳的音樂檔案可以被確定出來,同時我們再紀錄前端所生成的note數以及使用者遊玩的結果,上傳回資料庫即可完成。

const SHA256 = {
    sha256Buffer: async function(buffer) {
        return crypto.subtle.digest('SHA-256', buffer).then(function(hashBuffer) {
            const hashArray = Array.from(new Uint8Array(hashBuffer));
            const hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('');
            return hashHex;
        });
    },
    sha256Text: async function(text) {
        return this.sha256Buffer(new TextEncoder('utf-8').encode(text));
    }
};

CallForMusic

By Tommy Chiang

CallForMusic

  • 434