凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Twitter のアイコンを音楽に変換するサービス TwMidi を作った

はじめに

Twitter アイコンが音楽になったら良いなー、と思って作ってみました。

場所

音楽というか適当な音の羅列って感じになります。。

仕組み

コードはかなーり適当ですが、ざっと以下のような感じです。

  1. API 経由で twitterイコン画像の URL を取得
  2. http.get でその画像を Buffer に格納
  3. node-canvas でサーバ側の Canvas に Buffer を描画
  4. Canvas から取得したピクセルデータをもとに jsmidgen を利用して MIDI を生成

で、画像を音楽にするアルゴリズムですが、以下のような感じで適当にやっています。

  • アイコンの左上から縦に 4 px 刻みでピクセル情報を取得
  • 楽器は画像の中央ピクセルの色相から適当に決定
  • ドレミは RGB 情報から適当に生成
  • テンポは彩度で決定(彩度が高いほうがゆっくり)
  • 音の高さは明度で決定(明るいほうが高い)

という感じです。単色アイコンの方すみません。
コードは以下のようになります。

var Canvas      = require('canvas')
  , Image       = Canvas.Image
  , Midi        = require('jsmidgen')
  , http        = require('http')
  , fs          = require('fs')
  , exec        = require('child_process').exec
  , twitter     = require('twitter')
  , async       = require('async')
;

// ユーザー情報取得用 API
var user_lookup_api_url = 'https://api.twitter.com/1.1/users/lookup.json';

// Twitter へ接続
var twit = new twitter({
	consumer_key        : 'xxxxxxxxxx',
	consumer_secret     : 'xxxxxxxxxx',
	access_token_key    : 'xxxxxxxxxx',
	access_token_secret : 'xxxxxxxxxx'
});

// 音階
var notes = ['c','d','e','f','g','a','b'];

// 楽器
var instruments = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F];

// MIDI データ
function getNote(r, g, b)
{
	// 正規化
	r /= 255;
	g /= 255;
	b /= 255;

	// RGB -> HSV 変換
	var max = Math.max(r, g, b);
	var min = Math.min(r, g, b);
	var h = (max === min) ? 0
	      : (max === r) ? 60 * (g - b)/(max - min) + 0
	      : (max === g) ? 60 * (b - r)/(max - min) + 120
	      : 60 * (r - g)/(max - min) + 240;
	if (h < 0) h += 360;
	var s = (max === 0) ? 0 : (max - min) / max;
	var v = max;

	// HSV を MIDI に変換
	var scale  = Math.floor(r*g*b*7)%notes.length;
	var length = Math.floor(s * 96) + 12;
	var level  = Math.floor(v * 3) + 3;
	var symbol = notes[scale] + level;
	var instrument = instruments[Math.floor(h)%instruments.length];

	return {
		scale  : scale,
		level  : level,
		length : length,
		symbol : symbol,
		instrument : instrument
	};
}

// サーバを立てる
http.createServer(function(req, res) {
	var id = req.url.slice(1);
	if (id === 'favicon.ico') return;

	// アイコンを音楽に変換する
	async.waterfall([
		// Tiwtter からアイコンのURLを取得
		function (callback) {
			twit.get(user_lookup_api_url, {screen_name: id}, function(user_info) {
				console.log(user_info);
				if ('statusCode' in user_info && user_info.statusCode == 404) {
					callback('Error!: @' + id + ' is not found');
					return;
				}
				callback(null, user_info[0].profile_image_url);
			});
		},
		// アイコン画像を取得
		function (url, callback) {
			http.get(url, function(res) {
				// Buffer で読み込まないと img.src が上手くいかない
				var data = new Buffer(parseInt(res.headers['content-length'], 10))
				var pos  = 0;
				res.on('data', function(chunk) {
					chunk.copy(data, pos);
					pos += chunk.length;
				}).on('end', function() {
					// ストリームから画像を用意
					var img = new Image
					img.src = data;
					// ピクセルデータを得るためのキャンバスを用意
					var canvas  = new Canvas(img.width, img.height)
					var ctx     = canvas.getContext('2d')
					// 描画してピクセルデータを得る
					ctx.drawImage(img, 0, 0, img.width, img.height);
					var imgData = ctx.getImageData(0, 0, img.width, img.height);
					imgData['width']  = img.width;
					imgData['height'] = img.height;
					callback(null, imgData);
				});
			}).on('error', function(e) {
				callback(e, null);
			});
		},
		// 画像から MIDI を生成
		function (img, callback) {
			var d = img.data;
			var RGBA_NUM = 4;
			var SKIP_PX  = 4;

			var file  = new Midi.File();
			var track = new Midi.Track();
			file.addTrack(track);

			// 適当な楽器を当てはめる
			var m = img.width*img.height * 4 / 2;
			var note = getNote(d[m], d[m+1], d[m+2], d[m+3]);
			track.setInstrument(0, note.instrument);

			// 適当な音を当てはめる
			for (var i = 0; i < img.width; i += SKIP_PX) {
				for (var j = 0; j < img.height; j += SKIP_PX) {
					var k = i*RGBA_NUM + j*RGBA_NUM*img.width;
					var note = getNote(d[k], d[k+1], d[k+2], d[k+3]);
					track.addNote(0, note.symbol, note.length);
				}
			}
			callback(null, file);
		},
		// MIDI を書き出し
		function (file, callback) {
			res.writeHead(200,{'Content-Type': 'audio/midi'});
			res.write(file.toBytes(), 'binary');
			res.end();
		},
	], function(err) {
		res.writeHead(200,{'Content-Type': 'text/plain'});
		res.write(err);
		res.end();
	});
}).listen(50000);

// 例外処理
process.on('uncaughtException', function (err) {
	console.log('uncaughtException => ' + err);
});

おわりに

原理的にはすぐ出来たのですが、それっぽい音楽になるよう調整するところで苦労しました…。何か良いアルゴリズムあったら教えて下さい。あと楽器を含めて MIDI を再生する方法がアレばこちらも是非。ちなみに jasmid だと再生は出来たのですが楽器がピアノ固定になってしまいました。