Naciśnij “Enter” aby skoczyć do treści

Tiny Radio Player #04 – Budujemy silnik

Dzisiaj przejdziemy do budowy silnika, którego zadaniem będzie odtwarzanie internetowych stacji radiowych. Taki sposób pozwoli na zamknięcie wszystkich składowych odtwarzacza w jednym miejscu i odseparowanie go od innych części aplikacji. Możliwe stanie się również np. bezpośrednie podpięcie silnika pod aplikację konsolową.

Założenia

Każe uruchomienie bądź przełączenie stacji spowoduje utworzenie nowego wątku (z określonej puli). Jego zadaniem będzie próba utworzenia połączenia z serwerem i załadowanie wstępnych danych. Jeżeli operacja się powiedzie, nastąpi płynne przejście z odtwarzania jednej stacji do drugiej. Po pełnym przełączeniu wszystkie nieaktywne wątki zostaną zastopowane i usunięte.

Biblioteka Bass

Do odtwarzania użyjemy biblioteki Bass. Można ją pobrać ze strony www.un4seen.com. Z pobranego archiwum potrzebny jest nam tylko plik biblioteki. Po wypakowaniu umieszczamy go w odpowiednim katalogu:

  • Windows – plik bass.dll umieszamy w TinyRadioPlayer\bin\i386-win32\data\lib
  • Linux – plik libbass.so umieszczamy w TinyRadioPlayer/bin/x86_64-linux/data/lib
  • Mac OSX – plik libbass.dylib umieszczamy w TinyRadioPlayer/bin/i386-darwin/data/lib

Z racji tego, że biblioteka Bass we Free Pascalu powinna zostać załadowana dynamicznie, to zamiast używać, dostępnego w spakowanym archiwum, pliku bass.pas użyjemy lazdynamic_bass.pas. W taki sposób będziemy w stanie dynamicznie załadować plik biblioteki a następnie, przy zamknięciu aplikacji, wyładować go z pamięci. Po pobraniu umieszczamy go w źródłach aplikacji.

Wielowątkowość i zarządzanie pamięcią

W systemie Linux i Mac OSX, aby tworzyć aplikacje wielowątkowe musimy użyć modułu cthreads.  Powinien on zostać dodany jako pierwszy moduł projektu w pliku TinyRadioPlayer.lpr.

program TinyRadioPlayer;

{$mode objfpc}{$H+}

uses
  {$IFDEF UNIX}{$IFDEF UseCThreads}
  cthreads,
  cmem, // the c memory manager is on some systems much faster for multi-threading
  {$ENDIF}{$ENDIF}
  {$IFDEF DEBUG}
  // You can not use the -gh switch with the cmem unit.
  // The -gh switch uses the heaptrc unit, which extends the heap manager.
  // Therefore the heaptrc unit must be used after the cmem unit.
  heaptrc,
  {$ENDIF}
  Interfaces, // this includes the LCL widgetset

Dodatkowo, aby zoptymalizować proces zarządzania pamięcią dodajemy moduł cmem. Ta zmiana niestety pociąga za sobą konieczność wyłączenia analizy wycieków pamięci z ustawień trybu Debug. Aby to zrobić otwieramy ustawienia projektu Project -> Project Options… -> Compiler Options -> Debugging i odznaczamy Use Heaptrc unit (check for mem-leaks) (-gh). Zmiany wprowadzamy tylko dla trybu Debug.

Na koniec zostaje tylko dodać dyrektywy kompilatora, czyli  UseCThreads i DEBUG. Otwieramy Project -> Project Options… -> Compiler Options -> Custom Options i dla trybu Debug dodajemy -dUseCThreads i -dDEBUG a dla Release -dUseCThreads i -dRELEASE.

Własna klasa wątku

Nową klasę nazwiemy TRadioPlayerThread i utworzymy ją jako rozszerzenie klasy TThread.

  TRadioPlayerThread = class(TThread)
  private
    FActive: boolean;
    FThreadIndex: integer;
    FChannel: HSTREAM;
    FVolume: integer;  // main volume
    FReq: DWord;
    FCritSection: TCriticalSection;
    FChannelStatus: DWord;
    FStreamUrlToPlay: string;

    FPlayerMessage: string;
    FPlayerMessageType: TPlayerMessageType;

    // floating-point channel support? 0 = no, else yes
    FFloatable: DWord;

    // Custom events
    FOnStreamPlaying: TStreamStatusEvent;
    FOnStreamStopped: TNotifyEvent;
    FOnStreamPaused: TNotifyEvent;
    FOnStreamStalled: TNotifyEvent;
    FOnStreamGetTags: TStreamGetTagsEvent;

    procedure DoFadeIn(time: integer = 1000);
    procedure DoFadeOut(time: integer = 200);

    procedure OpenURL(url: PChar);
    procedure StreamStop;
    procedure MetaStream;
    procedure SendPlayerMessage(AMessage: string; AMessageType: TPlayerMessageType);
    procedure CheckBufferProgress;
  protected
    procedure Execute; override;
    procedure SynchronizePlayerMessage;
    procedure SynchronizeOnStreamPlaying;
    procedure SynchronizeOnStreamStopped;
    procedure SynchronizeOnStreamPaused;
    procedure SynchronizeOnStreamStalled;
  public
    constructor Create(CreateSuspended : boolean; Floatable: DWord = 0);
    destructor Destroy; override;

    function ErrorGetCode: Integer;
    function ChannelGetLevel: DWORD;
    function StreamGetFilePosition(mode: DWORD): QWORD;
    function ChannelIsActiveAndPlaying: Boolean;
    function ChannelIsActiveAndPaused: Boolean;
    function ChannelIsActiveAndStopped: Boolean;
    function ChannelIsActiveAndStalled: Boolean;
    function Pause: Boolean;
    function Stop: Boolean;
    function ChangeVolume(Value: Integer): Boolean;
    procedure PlayURL(AStreamUrl: string;
      const AVolume: Integer; const AThreadIndex: Integer);

    property OnStreamPlaying: TStreamStatusEvent read FOnStreamPlaying write FOnStreamPlaying;
    property OnStreamStopped: TNotifyEvent read FOnStreamStopped write FOnStreamStopped;
    property OnStreamPaused: TNotifyEvent read FOnStreamPaused write FOnStreamPaused;
    property OnStreamStalled: TNotifyEvent read FOnStreamStalled write FOnStreamStalled;
    property OnStreamGetTags: TStreamGetTagsEvent read FOnStreamGetTags write FOnStreamGetTags;
    property Active: boolean read FActive;
  end;

Implementację i opis wszystkich metod znajdziecie w repozytorium na GitHubie. W tym miejscu chciałbym jedynie przedstawić ogólne założenia. Po utworzeniu wątku zaczynamy oczekiwać na przekazanie adresu url stacji. Jeżeli taki się pojawi to próbujemy go otworzyć. Następnie podczas odtwarzania pobieramy informacje udostępniane przez daną stację. Każdy typ informacji przekazujemy przy pomocy podpiętych zdarzeń. Informujemy również o rozpoczęciu, zakończeniu i wstrzymaniu odtwarzania. Jeżeli zostanie przekazany kolejny url to aktualnie odtwarzana stacja zostaje wyciszona po czym następuje próba uruchomienia kolejnej stacji i stopniowe zwiększenie głośności.

Zarządzanie wątkami

Do zarządzania wątkami utworzymy oddzielną klasę o nazwie TRadioPlayer. Pod spodem będzie zajmować się tworzeniem, przełączaniem i usuwaniem wątków oraz inicjowaniem biblioteki bass oraz jej rozszerzeń. Na zewnątrz zostaną natomiast wystawione metody do zarządzania odtwarzaniem. Będzie naszym zewnętrznym interfejsem silnika.

  TRadioPlayer = Class(TObject)
  private
    FActiveRadioPlayerThread: integer;
    FRadioPlayerThreads: array [1..MAX_PLAYER_THREADS] of TRadioPlayerThread;
    FFloatable: DWord;
    FThreadWatcher: TTimer;
    FOnRadioPlayerTags: TRadioPlayerTagsEvent;
    FOnRadioPlay: TNotifyEvent;
    procedure Error(msg: string);
    procedure RadioInit;
    function LoadBassPlugins: Boolean;
    procedure RadioPlayerThreadsOnStreamPlaying(ASender: TObject; AThreadIndex: integer);
    procedure RadioPlayerThreadsStreamGetTags(ASender: TObject; AMessage:
      string; APlayerMessageType: TPlayerMessageType);
    procedure ThreadWatcherTimer(Sender: TObject);
    procedure TerminateThread(ThreadIndex: Integer; TerminateIfNotActive: Boolean);
    procedure CreateAndLaunchNewThread(ThreadIndex: Integer);
  public
    constructor Create; overload;
    destructor Destroy; override;

    procedure PlayURL(const AStreamUrl: string; const AVolume: ShortInt);

    function Stop(): Boolean;
    procedure Volume(Value: Integer);

    function ChannelIsActiveAndPlaying: Boolean;
    function ChannelGetLevel: DWORD;
    function NumberOfRunningThreads: integer;

    property OnRadioPlayerTags: TRadioPlayerTagsEvent
      read FOnRadioPlayerTags write FOnRadioPlayerTags;
    property OnRadioPlay: TNotifyEvent read FOnRadioPlay write FOnRadioPlay;
  end;

Maksymalną liczbę wątków MAX_PLAYER_THREADS możemy ustawić na 3. W taki sposób jeden z nich będzie zajmował się odtwarzaniem a dwa pozostałe (jeżeli zajdzie taka potrzeba) ustanawianiem połączenia. Jeżeli jakiś z wątków zwróci informację o rozpoczęciu odtwarzania to płynie się na niego przełączamy a pozostałe wątki zatrzymujemy i usuwamy. Aby sprawdzić, czy przełączanie faktycznie działa możemy zmienić ilość wątków na 1 i kilka razy kliknąć przycisk [Play]. Pomiędzy każdym kliknięciem powinna pojawić się przerwa w odtwarzaniu oznaczająca nawiązywanie połączenia. Jeżeli korzystamy z większej ilości wątków to nawiązywanie połączenia i buforowanie odbywa się w osobnych wątkach co eliminuje chwilę ciszy zastępując ją płynnym przejściem w odtwarzaniu wątków.

Podpięcie silnika pod GUI

Na zakończenie tworzymy instancję klasy silnika i podpinamy zdarzenia pod odpowiednie elementy GUI.

unit MainFormUnit;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, FileUtil, BCButton, BGRAFlashProgressBar, BCLabel, Forms,
  Controls, Graphics, Dialogs, LCLType, StdCtrls, ExtCtrls, Helpers,
  RadioPlayer, RadioPlayerTypes;

type

  TMainForm = class(TForm)
    btnStop: TBCButton;
    pbLeftLevelMeter: TBGRAFlashProgressBar;
    btnPlay: TBCButton;
    edtStreamUrl: TEdit;
    lblInfo1: TLabel;
    lblInfo2: TLabel;
    pbRightLevelMeter: TBGRAFlashProgressBar;
    sbVolume: TScrollBar;
    Timer1: TTimer;
    procedure btnPlayClick(Sender: TObject);
    procedure btnStopClick(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure RadioPlayerRadioPlay(Sender: TObject);
    procedure RadioPlayerRadioPlayerTags(AMessage: string; APlayerMessageType: TPlayerMessageType);
    procedure sbVolumeChange(Sender: TObject);
    procedure Timer1Timer(Sender: TObject);
  private

  public
    RadioPlayer: TRadioPlayer;
  end;

var
  MainForm: TMainForm;

implementation

{$R *.lfm}

procedure TMainForm.btnPlayClick(Sender: TObject);
begin
  RadioPlayer.PlayURL(edtStreamUrl.Text, sbVolume.Position);
end;

procedure TMainForm.btnStopClick(Sender: TObject);
begin
  RadioPlayer.Stop();
end;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  MainWindowHandle := Handle;

  RadioPlayer := TRadioPlayer.Create;
  RadioPlayer.OnRadioPlayerTags := @RadioPlayerRadioPlayerTags;
  RadioPlayer.OnRadioPlay := @RadioPlayerRadioPlay;
end;

procedure TMainForm.FormDestroy(Sender: TObject);
begin
  FreeAndNil(RadioPlayer);
end;

procedure TMainForm.RadioPlayerRadioPlay(Sender: TObject);
begin

end;

procedure TMainForm.RadioPlayerRadioPlayerTags(AMessage: string;
  APlayerMessageType: TPlayerMessageType);
begin
  case APlayerMessageType of
    Connecting: begin
      lblInfo1.Caption := 'Connecting';
    end;
    Error: begin
      lblInfo1.Caption := 'Idle: ' + AMessage;
    end;
    Progress: begin
      // Buffering progress
    end;
    StreamName: begin
      lblInfo2.Caption := AMessage;
    end;
    Bitrate: begin
      // bitrate
    end;
    StreamTitle: begin
      // title name, song name
      lblInfo1.Caption := AMessage;
    end;
    Other: begin
      lblInfo2.Caption := AMessage;
    end;
  end;
end;

procedure TMainForm.sbVolumeChange(Sender: TObject);
begin
  RadioPlayer.Volume(sbVolume.Position);
end;

procedure TMainForm.Timer1Timer(Sender: TObject);
var
  level: DWORD;
begin

  if RadioPlayer.ChannelIsActiveAndPlaying then
  begin
    level := RadioPlayer.ChannelGetLevel;

    pbLeftLevelMeter.Value := MulDiv(100, LoWord(level), 32768);
    pbRightLevelMeter.Value := MulDiv(100, HiWord(level), 32768);
  end else if (pbLeftLevelMeter.Value <> 0) or (pbRightLevelMeter.Value <> 0) then
  begin
    pbLeftLevelMeter.Value := 0;
    pbRightLevelMeter.Value := 0;
  end;
end;

end.

Dodatkowo na głównej formie umieszczamy Timer, który co 33 milisekundy będzie pobierał informację o natężeniu dźwięku i prezentował ją na progress barach.

I to już wszystko na dzisiaj. Cały kod aplikacji dostępny jest na GitHubie a zmiany dotyczące tego wpisu znajdziesz tu.