Pull to refresh

Создание домашней аудиосистемы

Reading time 11 min
Views 7K
Сразу оговорюсь, что я понимаю под домашней аудиосистемой.

Цель: управлять воспроизведением музыки в колонках с любого устройства в домашней сети.

Выглядит реализация следующим образом: в базе данных хранится информация о музыке, есть сервлет, который по запросу эту информацию вытягивает.
Управляется все через веб-интерфейс.
И, наконец, на андроид-устройстве, к которому подключены колонки, крутится аудио-сервер.

И так, поехали.

1. Готовим базу данных


В качестве базы данных будем использовать MySQL. База данных содержит две таблицы: mp3 — данные об аудиофайлах и mp3_tmp — таблица используется при обновлении базы данных. По структуре обе таблицы идентичны.

Таблицы содержат следующие поля:



path — путь к файлу на диске, PRIMARY KEY;
artist — исполнитель;
album — название альбома;
title — название трека;
year — год записи;
number — номер трека в альбоме;
length — длина трека в формате mm:ss.

Итак, SQL для создания таблицы:
DROP TABLE IF EXISTS `mp3_tmp`;
CREATE TABLE `mp3_tmp` (
	`path` varchar(250) NOT NULL,
	`artist` varchar(250) DEFAULT NULL, 
	`album` varchar(250) DEFAULT NULL, 
	`title` varchar(250) DEFAULT NULL, 
	`year` varchar(40) DEFAULT NULL, 
	`length` varchar(40) DEFAULT NULL,
	`number` varchar(40) DEFAULT NULL,
	PRIMARY KEY (`path`))
ENGINE=InnoDB DEFAULT CHARSET=utf8;


Для формирования таблицы используем программу на Java.

Для начала сформируем список всех mp3-файлов в папке с музыкой:

FileFinder ff = new FileFinder(); //класс для поиска файлов, описание алгоритма опускаю
List<File> files = ff.findFiles(initPath, ".*\\.mp3"); // ищем все mp3-файлы в директории initPath


Далее вносим данные о файлах во временную таблицу
(используется JDBC — для соединения с базой данных и
библиотека jaudiotagger — для сканирования тегов mp3):

for (File file : files) {
	// Подготавливаем запрос:
	PreparedStatement preparedStatement = 
		connect.prepareStatement("insert into `mp3_tmp` " 
										+ "(path, artist, album, title, year, number, length) "
										+ "values (?, ?, ?, ?, ?, ?, ?)");
		
	String fullName = file.getCanonicalPath();
	String fileName = fullName.replace(initPath,""); //будем хранить в базе данных относительный путь

	String length = "";
	
	try {
		AudioFile af = AudioFileIO.read(file);
		int len = af.getAudioHeader().getTrackLength(); //получаем длину трека
		int sec = len%60;
		int min = (len-sec)/60;
		length = String.format("%02d:%02d", min, sec); //форматируем длину
				
		MP3File mp3f = new MP3File(file);
				
		Tag tag = mp3f.getTag(); //получаем теги
		String artist = tag.getFirst(FieldKey.ARTIST);
		String album = tag.getFirst(FieldKey.ALBUM);
		String title = tag.getFirst(FieldKey.TITLE);
		String year = tag.getFirst(FieldKey.YEAR);
		String number = tag.getFirst(FieldKey.TRACK);
		
		//приводим номер трека к трехзначному виду (для удобства сортировки):
		if (!number.equals("")){
			Integer num = Integer.parseInt(number);
			if (num < 10) {
				number = "00"+num.toString();
			} else if (num < 100) {
				number = "0"+num.toString();
			}
		}
		
		//подставляем данные в запрос:
		preparedStatement.setString(1, fileName);
		preparedStatement.setString(2, artist);
		preparedStatement.setString(3, album);
		preparedStatement.setString(4, title);
		preparedStatement.setString(5, year);
		preparedStatement.setString(6, number);
	} catch (Exception e) {
		//в случае ошибки получения тега, заполняем пустыми строками:
		preparedStatement.setString(1, fileName);
		preparedStatement.setString(2, "");
		preparedStatement.setString(3, "");
		preparedStatement.setString(4, "");
		preparedStatement.setString(5, "");
		preparedStatement.setString(6, "");
	} finally {
		preparedStatement.setString(7, length);
		preparedStatement.executeUpdate(); //добавляем трек в базу данных
	}
}
			
//И, наконец, обновляем основную таблицу:

statement.execute("DROP TABLE IF EXISTS `mp3`");
statement.execute("CREATE TABLE `mp3` LIKE `mp3_tmp`");
statement.execute("INSERT INTO `mp3` SELECT * FROM `mp3_tmp`");


2. Backend плеера


Бэкенд плеера состоит из двух сервлетов:

@WebServlet("/getlist") — возвращает список треков по поисковому запросу;
@WebServlet("/hint") — возвращает подсказки по первым буквам.

Подробнее про сервлет GetList.

//формируем SQL-запрос
String query = "select path, artist, title, album, year, length from `mp3` where ";
String[] qArr0 = request.getParameter("query").split("\\|"); 
for (int k=0; k<qArr0.length; k++) {
	if (k>0) query += " or ";
    String[] qArr = qArr0[k].split(" ");
    query += "concat(title,' ',album,' ',artist) like "+"'%"+qArr[0]+"%' ";
    for (int j=1; j<qArr.length; j++) {
     	query += "and concat(title,' ',album,' ',artist) like "+"'%"+qArr[j]+"%' ";
    }
}
query += "group by concat(year,album,number,title,artist,length) ";
query += "order by concat(year,' ',album,' ',number,' ',title,' ',artist)";

Statement statement = connect.createStatement();
resultSet = statement.executeQuery(query); //получаем данные
            	
//Используем библиотеку GSON для формирования json
Gson gson = new GsonBuilder().create();
            	
int i=0;
while (resultSet.next()) {
    if (i++>0) playlist+="\n";
    String artist = resultSet.getString("artist");
    String title = resultSet.getString("title");
    String album = resultSet.getString("album");
    String year = resultSet.getString("year");
    String path = resultSet.getString("path");
    path = musicPath+path;
    String length = resultSet.getString("length");
    Track track = new Track(title, artist, album, year, path, length);
    playlist += gson.toJson(track);
}


Сервлет Hint выполняет следующий SQL-запрос:

// String text = request.getParameter("query");
Statement	statement = connect.createStatement();
resultSet = statement.executeQuery("select title as 'str' from "
                        + "`mp3` where title like '%"
                        + text + "%' union "
                        + "select artist as 'str' from "
                        + "`mp3` where artist like '%"
                        + text + "%' union "
                        + "select album as 'str' from "
                        + "`mp3` where album like '%"
                        + text + "%' group by str order by str limit 10");


и возвращает данные в json-формате.

3. HTML5 frontend


В качестве фронтенда плеера используем HTML5 и jQuery. Здесь интересны следующие моменты.

Формирование плейлиста:

$('#search').click(function(){
    //Получаем трек-лист от сервлета
    var uri = '/mp3player/getlist?query='+$('#query').val();
    $.get(encodeURI(uri),function(data){
       	window.playlist = [];
       	window.number = 0;
        var array = data.split('\n');
        for (i=0; i<array.length; i++) { 
           	window.playlist[i] = $.parseJSON(array[i]);
        }
		
		// Формируем HTML списка:
        var li_list = '<ol>';
        var album = '';
        var year = '';
        var alb_num = 0;
        for (i=0; i<window.playlist.length; i++) {
            if (album != window.playlist[i].album || i == 0) {
            	album = window.playlist[i].album;
            	year = window.playlist[i].year;
            	var str = 'Неизвестный альбом';
            	if (album) str = album;
            	if (year) str+= ' | ' + year;
            	li_list += '<h4 class="album" alb_num='+alb_num+'>'+str+'</h4>';
            	alb_num++;
            }
            li_list += '<li class="track" id='+i+' alb_num='+alb_num+'>'
                +window.playlist[i].title+' ('+window.playlist[i].artist+') | '+window.playlist[i].length+'</li>';
        }
        li_list += '</ol>';
		
		// Обновляем страницу:
        $('#list').html(li_list);
        if (window.playlist.length>1) {
    	  	window.alb_num = $('li#'+window.number).attr('alb_num');
       	   	$('#list').scrollTop($('li#'+window.number).offset().top-$('li#0').offset().top);
       	}
		
		// При клике по треку начинаем проигрывание:
        $('li.track').click(function(){
         	$('#toogle').prop('disabled',false);
           	$('#toogle').prop('checked',true);
      	    $('li#'+window.number).attr('style','font-weight:normal; font-style:normal');
            window.number = $(this).attr('id');
         	if (window.playlist.length>1) {
         		window.alb_num = $('li#'+window.number).attr('alb_num');
        	 	$('#list').animate({scrollTop:($('li#'+window.number).offset().top-$('li#0').offset().top)},200);
        	}
           	$('li#'+window.number).attr('style','font-weight:bold; font-style:italic');
            $('#my_audio').trigger('pause'); // #my_audio - html5 элемент audio. Ставим на паузу.
            var track = window.playlist[window.number];
            $('#title').html('<h3>'+(Number(window.number)+1)+': '+track.title+' ('+track.artist+')</h3>');
            $('#my_audio').attr('src',track.mp3); // Устанавливаем новый источник
            $('#my_audio').trigger('play'); // Включаем проигрывание
        });
    });
});


Подсказки:

// используем плагин jquery.autocomplete
$('#query').autocomplete({serviceUrl:'/mp3player/hint'});   


Изменение громкости:

$('#volume_slider').slider({orientation:'vertical',range:'min',min:0,max:100,value:100,stop:function(event,ui){
	var volume = (ui.value*1.0)/100.0;
	$('#my_audio').prop('volume',volume);
} });    


Проматывание трека:

$('#time_slider').slider({disabled:true,range:'min',min:0,max:1000,stop:function(event, ui) {
	var dur = $('#my_audio').prop('duration');
    var cur = (dur*ui.value)/1000;
    $('#my_audio').prop('currentTime',cur);
} });


Интерактивное отображение текущей позиции:

$('#my_audio').bind('timeupdate',function(){
	var cur = $('#my_audio').prop('currentTime');
	var dur = $('#my_audio').prop('duration');
	var left = dur - cur;
	if (dur) {
 	    var slider_val = cur*1000/dur;
		cur = Math.floor(cur+0.5);
		dur = Math.floor(dur+0.5);
		left = Math.floor(left+0.5);
		cur_s = cur % 60;
		cur_m = (cur - cur_s)/60;
		dur_s = dur % 60;
		dur_m = (dur - dur_s)/60;
		left_s = left % 60;
		left_m = (left - left_s)/60;
		cur_s = $.formatNumber(cur_s,{format:'00',locale:'ru'});
		cur_m = $.formatNumber(cur_m,{format:'00',locale:'ru'});
		dur_s = $.formatNumber(dur_s,{format:'00',locale:'ru'});
		dur_m = $.formatNumber(dur_m,{format:'00',locale:'ru'});
		left_s = $.formatNumber(left_s,{format:'00',locale:'ru'});
		left_m = $.formatNumber(left_m,{format:'00',locale:'ru'});
		$('#time_cur').text(cur_m+':'+cur_s+'  ')
		$('#time_dur').text('  '+left_m+':'+left_s);
		$('#time_slider').slider('option',{disabled:false});
	    $('#time_slider').slider('value',slider_val);
	}
});


Переключение на следующий трек по окончании:

$('#my_audio').on('ended',function(){
    var n = (Number(window.number) + 1) % window.playlist.length;
    $('li#'+n).trigger('click');
});


Готовый плеер можно увидеть здесь:
http://home.tabatsky.ru/mp3player/homeaudio/desktop.jsp

4. Аудиосервер


Так получилось, что у меня без дела пылились хорошие колонки и одно устройство на андроиде. В итоге появилась идея написать для андроида аудиосервер и слушать музыку через колонки.

Аудиосервер состоит из одной Activity и двух сервисов — HttpService и PlayerService.

И так, подробнее.

HttpService принимает HTTP-запросы и отправляет команды PlayerService.

public int onStartCommand(Intent intent, int flags, int startId) {
    t = new Thread() {
		public void run() {
			try {
				ss = new ServerSocket(port, backlog, InetAddress.getByName(addr));
				while (true) {
					// Принимаем и обрабатываем запросы
					Socket s = ss.accept();
					Thread tt = new Thread(new SocketProcessor(s));
					tt.start();
					tt.join(50);
				}
			} catch (Throwable e) {
				e.printStackTrace();
			}
		}
	};
	t.start();
	    
	// BroadcastReceiver для связи c PlayerService
	filter = new IntentFilter("Http");
	receiver = new BroadcastReceiver() {
		@Override
		public void onReceive(Context context, Intent intent) 
	    {
            String res = intent.getStringExtra("result");
	        if (res!=null) result = "'"+res+"'";
	        String stat = intent.getStringExtra("status");
	        if (stat!=null)  status = "'"+stat+"'";
	        String cur = intent.getStringExtra("currentTime");
	        if (cur!=null) currentTime = cur;
	        String dur = intent.getStringExtra("duration");
	        if (dur!=null) duration = dur;
	    }
	};
	registerReceiver(receiver, filter);
		
    return START_STICKY;
}

// Класс отвечает за обработку HTTP-запросов		
private class SocketProcessor implements Runnable {

    private Socket s;
    private InputStream is;
    private OutputStream os;

    private SocketProcessor(Socket s) throws Throwable {
        this.s = s;
        this.is = s.getInputStream();
        this.os = s.getOutputStream();
    }

    public void run() {
        try {
		    // Считываем заголовки HTTP-запроса:
            readInputHeaders();
			// Формируем и возвращаем результат запроса в формате jsonp:
            String response = "";
            response += "window.result="+result+"; ";
            response += "window.status="+status+"; ";
            response += "window.currentTime="+currentTime+"; ";
            response += "window.duration="+duration+"; ";
            writeResponse(response);
        } catch (Throwable t) {
            /*do nothing*/
        } finally {
            try {
                s.close();
            } catch (Throwable t) {
                /*do nothing*/
            }
        }
        System.err.println("Client processing finished");
    }

    private void writeResponse(String s) throws Throwable {
        String response = "HTTP/1.1 200 OK\r\n" +
                    "Server: YarServer/2009-09-09\r\n" +
                    "Content-Type: text/javascript\r\n" +
                    "Content-Length: " + s.length() + "\r\n" +
                    "Connection: close\r\n\r\n";
        String result = response + s;
        os.write(result.getBytes());
        os.flush();
    }

    private void readInputHeaders() throws Throwable {
        BufferedReader br = new BufferedReader(new InputStreamReader(is));
        String data = "";
        String action = "";
        String src = "";
        String volume = "";
        while(true) {
            String s = br.readLine();
            //System.err.println(s);
            //data += s+"\n";
            if (s.startsWith("GET /favicon.ico")) return;
            if (s.startsWith("GET /?")) {
              	s = s.replace("GET /?", "").replace(" HTTP/1.1", "");
               	String[] arr = s.split("&");
               	for (String str: arr) {
                   	str = URLDecoder.decode(str, "UTF-8");
               		if (str.startsWith("action=")) 
               			action = str.replace("action=", "");
               		if (str.startsWith("src=")) 
               			src = str.replace("src=", "");
               		if (str.startsWith("volume="))
               			volume = str.replace("volume=", "");
               	}
            }
            if(s == null || s.trim().length() == 0) {
                break;
            }
        }
		
		// Отправляем broadcast плееру
        Intent in = new Intent("Player");
    	in.putExtra("action", action);
    	in.putExtra("src",src);
    	in.putExtra("volume", volume);
    	sendBroadcast(in);
        }
    }
}


PlayerService отвечает за проигрывание музыки:


public int onStartCommand(Intent intent, int flags, int startId) {
	player = new MediaPlayer();
	player.setAudioStreamType(AudioManager.STREAM_MUSIC);
		
	player.setOnPreparedListener(new OnPreparedListener() { 
	    @Override
	    public void onPrepared(MediaPlayer mp) {
	        mp.start();
	        result = "ok";
	        status = "playing";
	    }
	});
		
	player.setOnCompletionListener(new OnCompletionListener() {
		@Override
		public void onCompletion(MediaPlayer mp) {
			//player.stop();
			result = "ok";
			if (mp.getCurrentPosition()>mp.getDuration()-2500) 
			status = "finished";
		}
    });
		
	//принимаем команды от HttpService	
	filter = new IntentFilter("Player");
	receiver = new BroadcastReceiver() {
		
		@Override
		public void onReceive(Context context, Intent intent) 
	    {
            String action = intent.getStringExtra("action");
	        if (action.equals("play")) {
	           	player.start();
	          	status = "playing";
	        }
	        if (action.equals("pause")) {
	           	player.pause();
	          	status = "paused";
	        }
	        if (action.equals("changesrc")) {
	            src = intent.getStringExtra("src");
	            try {
	            	status = "preparing";
	            	player.reset();
					//host - адрес нашего сервера с музыкой
	            	player.setDataSource(host+src);
	        		player.prepare();
		           	//player.start();
	        		result = "ok";
	        	} catch (Exception e) {
	        		result = "error";
	        	}
	        }
	        if (action.equals("setvolume")) {
	            float volume = Float.parseFloat(intent.getStringExtra("volume"));
	            player.setVolume(volume, volume);
	        }
	    }
	};
	registerReceiver(receiver, filter);
		
	//каждые 100 мс отправляем данные в HttpService	
	t = new Thread() {
		public void run() {
			while (true) {
				in = new Intent("Http");
				in.putExtra("result", result);
				//in.putExtra("status", (player.isPlaying()?"playing":"paused"));
				in.putExtra("status", status);
				in.putExtra("currentTime", 
					Integer.valueOf(player.getCurrentPosition()/1000).toString());
				in.putExtra("duration", 
					Integer.valueOf(player.getDuration()/1000).toString());
				sendBroadcast(in);
				try {
					sleep(100);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					//e.printStackTrace();
				}
			}
		}
	};
	t.start();
		
	return START_STICKY;
}


Осталось только немного изменить соответствующий код фронтенда.


//Изменяем трек
$('li.track').click(function(){
	........
	// window.audioServer - адрес сервера с музыкой
	var url = window.audioServer+'/?action=changesrc&src='+track.mp3+'&t='+(new Date().getTime());
    $.ajax({
        url: encodeURI(url),
        type: 'GET',
        crossDomain: true,
        dataType: 'jsonp'
    });
	.........
}
			
//Изменяем громкость
$('#volume_slider').slider({orientation:'vertical',range:'min',min:0,max:100,value:100,stop:function(event,ui){
	var volume = (ui.value*1.0)/100.0;
	//$('#my_audio').prop('volume',volume);
	window.setVolume(volume);
} });

window.setVolume = function(volume) {
	var url = window.audioServer+'/?action=setvolume&volume='+volume
					+'&t='+(new Date().getTime());
	$.ajax({
   		url: encodeURI(url),
   		type: 'GET',
   		crossDomain: true,
   		dataType: 'jsonp'
   	});
};

//Слушаем статус плеера
window.startUpdate = function() {
	window.update_interval = setInterval(function(){
	   	var url = window.audioServer+'/?action=update'+'&t='+(new Date().getTime());;
		$.ajax({
       		url: encodeURI(url),
       		type: 'GET',
       		crossDomain: true,
       		dataType: 'jsonp'
       	});		
		if (window.result=='error') {
				//alert('Ошибка');
		} else if (window.result=='ok') {
			if (window.status=='playing') {
				if (!window.fin) {
					window.fin = 0;
				} else {
					window.fin--;
				}
				window.updateTime(window.currentTime, window.duration);
			} else if ((window.fin==0)&&(window.status=='finished')) {
				window.fin = 2;
				$('#fwd').trigger('click');
			}
		}
	},500);
};


Итог: проигрыванием музыки на колонках можно управлять с любого устройства, подключенного к домашней сети.
Tags:
Hubs:
-4
Comments 11
Comments Comments 11

Articles