Простой mp3-плеер с графическим интерфейсом на Go

43bd22ed95c3010e8250a52212650c2e.png

Мы продолжаем рассматривать разные платформенные применения Go (ранее мы посмотрели как использовать Go для микроконтроллеров, веб-приложений, разработки API, создания мобильных приложений) и сегодня поговорим об использовании Go для создания приложений для настольных компьютеров на примере реализации несложного mp3-плеера с поддержкой графического интерфейса. Мы будем использовать связывание с GTK для реализации интерфейса, библиотеки декодирования mp3 и взаимодействия с аудиосистемой (для Windows, Linux и MacOS).

Прежде всего начнем с того, что при компиляции Go финальным артефактом может быть исполняемый файл для Windows / Linux / MacOS. Поскольку на этапе связывания приложение может взаимодействовать с системными и прикладными библиотеками, разработанными для соответствующей операционной системы, мы можем использовать возможности кроссплатформенных графических библиотек (например, GTK) и библиотеки для подключения к аудиосерверам (например, pulseaudio или более новый pipewire). Начнем рассмотрение с изучения методов связывания С-библиотек и кода на Go:

Для примера создадим простую функцию на C, которая будет суммировать два числа и соответствующую обертку на Go для ее вызова (разместим в c/sum.c):

int sum(int a, int b) {
  return a+b;
}

Теперь в коде на Go добавим в комментарий импорт исходного текста на C (через директиву #include) и импортируем именованный пакет «C», в результате получим при компиляции экспортированные символы в псевдопакете C (наряду с другими функциями, например преобразования числа Go в целочисленную переменную C, для работы с указателями и др). Для вызова нашей функции создадим функцию-обертку на Go:

package main

/*
#include "c/sum.c"
*/
import "C"

import (
	"errors"
	"fmt"
)

func main() {
	fmt.Println("Hello, World!")
	val, _ := sum(1, 2)
	fmt.Printf("Sum: %d\n", val)
}

func sum(a, b int) (int, error) {
	// val, _ := a+b, 0
	val, err := C.sum(C.int(a), C.int(b))
	if err != nil {
		return 0, errors.New("Error calling sum " + err.Error())
	}
	return int(val), nil
}

Наиболее важные функции из пакета С:

  • CString(str) — возвращает указатель на C-представление строки из Go

  • CBytes([]byte) — указатель на массив байт (в действительности на копию)

  • GoString(ptr) — преобразует C-строку в строку для Go

  • GoStringN(ptr, N) — преобразует C-строку с заданной длиной в строку Go

  • GoBytes(ptr, N) — последовательность байт длиной N для Go (тип []byte)

  • free(unsafe.Pointer(ptr)) — освобождение памяти под указателем (часто используется с defer), при подключении stdlib.h

Эти функции могут быть полезны при разработке собственных оберток вокруг динамических библиотек, а также для понимания принципов работы существующих go-модулей, которые используют внешние загружаемые библиотеки (например, GTK). При импорте можно использовать как файлы с исходными кодами, так и header-файлы (в этом случае будет использоваться связывание с библиотекой при сборке приложения).

Теперь посмотрим на реализацию связывания Go с библиотеками Gtk. Будем использовать проект https://github.com/gotk3/gotk3. Он предоставляет обертки вокруг GTK-функций и использует тот же подход компиляции через cgo, что и был рассмотрен ранее. Создадим простое окно на экране через использование этого пакета:

package main

import (
	"log"

	"github.com/gotk3/gotk3/gtk"
)

func main() {
	gtk.Init(nil)

	//новое окно
	win, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL)
	if err != nil {
		log.Fatal("Unable to create window:", err)
	}
	win.SetTitle("MP3 Player")
	win.Connect("destroy", func() {
		//при событии закрытия окна отключаемся от gtk
		gtk.MainQuit()
	})

    ui(win)
	win.SetDefaultSize(400, 300)
	//отображение окна и запуск цикла событий
	win.ShowAll()
	gtk.Main()
}

func ui(win *gtk.Window) {
}

Наш mp3-проигрыватель будет принимать название файла как аргумент командной строки и отображать на окне одну кнопку — Play/Pause. Добавим кнопку в функцию ui:

func ui(win *gtk.Window) {
	button, _ := gtk.ButtonNew()
	button.Connect("clicked", func() {
		//toggle state
	})
	button_label, _ := gtk.LabelNew("Play/Pause")
	button.Add(button_label)
	win.Add(button)
}

Теперь добавим информацию об mp3-файле, для этого разделим окно по вертикали и в верхнюю часть будем отображать метку (создаем через gtk.LabelNew) с названием из метаданных mp3-файла. Для чтения метаданных будем использовать пакет github.com/bogem/id3v2. Добавим в импорты «github.com/bogem/id3v2/v2» и «os» для получения аргументов командной строки и прочитаем метаданные mp3:

func main() {
	mp3 := os.Args[1]
	tag, errid3 := id3v2.Open(mp3, id3v2.Options{Parse: true})
	if errid3 != nil {
		log.Fatalln("File not found or error in metadata extraction")
	}
//...
}

Также будем использовать построение с использованием сетки (gtk.Grid) и разместим кнопку под заголовок с названием из метаданных mp3 (каждый виджет будет занимать размер 1×1 элемент сетки):

func ui(win *gtk.Window, tag *id3v2.Tag) {
	layout, _ := gtk.GridNew()

	title, _ := gtk.LabelNew(tag.Title())
	layout.Attach(title, 0, 0, 1, 1) //верхний ряд

	button, _ := gtk.ButtonNew()
	button.Connect("clicked", func() {
		//toggle state
	})
	button_label, _ := gtk.LabelNew("Play/Pause")
	button.Add(button_label)
	//кнопка под меткой
	layout.AttachNextTo(button, title, gtk.POS_BOTTOM, 1, 1)
	win.Add(layout)
}

Теперь, когда мы имеем простой графический интерфейс, подключимся к аудиосерверу нашей операционной системы. Если мы используем Linux, то можно задействовать alsa, pulseaudio или pipewire (в зависимости от дистрибутива). MacOS используется интерфейс CoreAudio, для Windows — Windows Core Audio. Наиболее универсальным выглядят решения с использованием PortAudio (github.com/gordonklaus/portaudio) или библиотеки github.com/hajimehoshi/oto, которая позволяет воспроизводить звук на любой операционной системе (при этом для Windows и MacOS используется нативная реализация протокола, без cgo, в частности для MacOS будет присоединяться AudioToolbox.framework на этапе сборки исполняемого файла). В библиотеке oto для взаимодействия с аудиосервером используется Context, а для воспроизведения звука из декодированного аудиофайла — Players (создается из контекста).

Также от этого автора представлена библиотека декодирования mp3-файла, которая может взаимодействовать с библиотекой воспроизведения звука. Добавим необходимые импорты:

import (
...
    "github.com/hajimehoshi/go-mp3"
    "github.com/hajimehoshi/oto/v2"
)

Выполним инициализацию контекста и плеера (свяжем с mp3-файлом):

func initOto(file string) {
	log.Println("Loading mp3 from " + file)
	data, e1 := os.Open(file)
	if e1 != nil {
		log.Fatalln(e1.Error())
	}
	decodedStream, e2 := mp3.NewDecoder(data)
	if e2 != nil {
		log.Fatalln(e2.Error())
	}
	otoCtx, readyChan, e3 := oto.NewContext(44100, 2, 2)
	if e3 != nil {
		log.Fatalln(e3.Error())
	}
	//ждем завершения инициализации
	<-readyChan
	player = otoCtx.NewPlayer(decodedStream)
	loaded = true

	//сохраним объект для использования, пока окно открыто
	players := make([]oto.Player, 1, 1)
	players[0] = player
	runtime.KeepAlive(players)
}

Поскольку при воспроизведении будет нужно изменять надпись на кнопке (Play <--> Pause) перенесем объект метки для кнопки в глобальную переменную и реализуем логику запуска/приостановки воспроизведения:

var button_label *gtk.Label
var player oto.Player
var playing bool
var loaded bool

func ui(win *gtk.Window, tag *id3v2.Tag) {
	layout, _ := gtk.GridNew()

	title, _ := gtk.LabelNew(tag.Title())
	layout.Attach(title, 0, 0, 1, 1) //верхний ряд

	button, _ := gtk.ButtonNew()
	button.Connect("clicked", func() {
		if loaded {
			if playing {
				log.Println("Pause")
				player.Pause()
				button_label.SetLabel("Play")
			} else {
				log.Println("Play")
				player.Play()
				button_label.SetLabel("Pause")
			}
			playing = !playing
		}
	})
	button_label, _ = gtk.LabelNew("Play")
	button.Add(button_label)
	//кнопка под меткой
	layout.AttachNextTo(button, title, gtk.POS_BOTTOM, 1, 1)
	win.Add(layout)
}

После запуска (go run. test.mp3 или go build. && ./player test.mp3) появится окно с названием из метаданных mp3 и кнопкой «Play», при нажатии на которую начнется воспроизведение mp3-файла (кнопка будет заменена на Pause и будет приостанавливать воспроизведение). Однако, при завершении mp3-файла надпись на кнопке останется «Pause». Давайте это исправим, для этого мы будем отслеживать текущее состояние player.IsPlaying в goroutine и изменять соответствующие флаги и отображение на кнопке (добавим в конец initOto):

	go func() {
		playing = player.IsPlaying()
		for {
			if player.IsPlaying() != playing {
				playing = player.IsPlaying()
				if playing {
					button_label.SetLabel("Pause")
				} else {
					button_label.SetLabel("Play")
				}
			}
			time.Sleep(16 * time.Millisecond) //60fps
		}
	}()

и теперь в callback-функции для нажатия кнопки будем только управлять состоянием воспроизведения:

	button.Connect("clicked", func() {
		if loaded {
			if playing {
				log.Println("Pause")
				player.Pause()
			} else {
				log.Println("Play")
				player.Play()
			}
		}
	})

Теперь осталось добавить индикатор позиции воспроизведения. Текущее положение можно получить из объекта decodedStream, а полный размер потока (в байтах) через decodedStream.Length. Добавим индикатор (gtk.ProgressBar) на экран (в ui):

	progress, _ = gtk.ProgressBarNew()
	layout.AttachNextTo(progress, button, gtk.POS_BOTTOM, 1, 1)

в загрузке (после loaded=true):  

	lengthInBytes := decodedStream.Length()

в цикле проверки состояния:

			if playing {
				pos, _ := decodedStream.Seek(0, io.SeekCurrent)
				var fraction float64
				fraction = float64(pos) / float64(lengthInBytes)
				progress.SetFraction(fraction)
			}

Текущее положение также может быть получено в секундах (поскольку decodedStream это сырой поток несжатых данных, то одна секунда содержит 44100×2 * 2 байт). Также при установке нового положения нужно задавать смещение кратное 4 байтам (чтобы указывать на начало сэмпла). Последнее, что мы сделаем сейчас — возможность перемещения позиции воспроизведения при нажатии на индикатор положения. И здесь мы столкнемся с проблемой, что при регистрации callback-функции для контейнера будет получаться объект gdk.Event (внутри GTK это union, интерпретация которого зависит от типа события). В GDK библиотеке есть структура ButtonEvent, но из gdk.Event можно получить только Native представление структуры. Поэтому для извлечения координаты мыши при нажатии на элемент управления будем использовать unsafe.Pointer.

Сначала обернем виджет ProgressBar в контейнер EventBox и затем добавим обработчик события button-press-event через eventbox.Connect. Фрагмент функции ui может выглядить подобным образом:

	eventbox, _ := gtk.EventBoxNew()
	eventbox.Connect("button-press-event", func(widget *gtk.EventBox, event *gdk.Event) {
		allocation := widget.GetAllocation()
		width := allocation.GetWidth()
		//получаем координату X из структуры gdk.ButtonEvent
		x := *(*float64)(unsafe.Pointer(event.Native() + 24))
		//получаем долю общей продолжительности
		fraction := float64(x) / float64(width)
		//определяем смещение (с округлением до 4)
		offset := int64(float64(decodedStream.Length()) * fraction)
		offset = offset / 4 * 4 //align to sample
		//перемещаем воспроизведение на смещение
		player.(io.Seeker).Seek(offset, io.SeekStart)
		log.Println("Seek to fraction ", fraction)
	})
	progress, _ = gtk.ProgressBarNew()
	eventbox.Add(progress)
	layout.AttachNextTo(eventbox, button, gtk.POS_BOTTOM, 1, 1)

Мы создали простой проигрыватель MP3-файлов для desktop-операционных систем (Windows, Linux, MacOS) с использованием связываний на графическую библиотеку GTK и универсальные библиотеки для подключения к аудиосерверу. Разумеется, нужно предусмотреть также обработку ошибок, поддержку управления громкостью и многое другое, но основная задача была в рассмотрении принципов создания desktop-приложений с поддержкой мультимедиа в Go.

Исходный текст программы опубликован в github-репозитории.

GitHub — dzolotov/gomp3player: Simple Go + GTK MP3 Player

github.com

В заключение приглашаю всех на бесплатный урок курса Golang Developer, где разберем, что такое «дженерики» и как они нам могут помочь в ежедневных задачах. А также разберем, как они влияют на производительность и чем они лучше/хуже обычных интерфейсов.

© Habrahabr.ru