Sulla Programmazione

Quattro chiacchere sulla programmazione e sulle bit-tecnologie con Fabrizio Cipriani

Semplice VOIP con java

I sorgenti degli esempi dell'articolo possono essere scaricati qui: javavoip.zip (7,86 kb).

Mi sono trovato spesso a chiedermi quanto potesse essere difficile, dal punto di vista dello sviluppatore, inviare e ricevere audio su internet. Il processo sembra semplice:

  1. Il trasmittente ed il ricevitore concordano il formato dei dati dell'audio (quanti samples al secondo, di quanti bit è formato un sample, il numero di canali, etc.)
  2. Il trasmittente stabilisce una connessione internet con il ricevitore
  3. Il trasmittente cattura l'audio da un fonte
  4. Il trasmittente invia l'audio al ricevitore
  5. Il ricevitore, prende l'audio e lo memorizza in un buffer
  6. Il ricevitore preleva i dati dal buffer e li riproduce alla velocità e alla qualità definita.

In realtà il processo così semplice non è, visto che stiamo parlando di cattura, playback, threads e trasmissione di pacchetti su internet.

Nel caso in questione, sono rimasto stupito dalla semplicità e dalla potenza fornita dal pacchetto javax.sound. Questo package semplifica il controllo degli input ed output audio su un pc, ed ha i meriti di essere presente nei runtime di java a partire dalla versione 1.3, potendo così essere utilizzato su qualsiasi computer, browser e sistema operativo che abbia java installato.

Riesaminiamo i punti del processo di invio, e creiamo in base ai requisiti una classe client e una classe server per lo scambio di dati audio. Chiamiamo la classe che funge da trasmittente "AudioSender", e la classe che funge da ricevitore "AudioReceiver".

Per AudioSender, definiamo la classe e le variabili membro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import java.net.\*;  
import java.io.\*;  
import javax.sound.sampled.AudioFormat;  
import javax.sound.sampled.AudioInputStream;  
import javax.sound.sampled.AudioSystem;  
import javax.sound.sampled.DataLine;  
import javax.sound.sampled.LineUnavailableException;  
import javax.sound.sampled.TargetDataLine;

public class AudioSender extends Thread                    
{                                                          
  private static final int INTERNAL_BUFFER_SIZE = 40960;   
  private TargetDataLine m_targetLine;                     
  private Socket m_callSocket;                             
  private AudioFormat m_audioFormat;                       
  private DataOutputStream m_dataOutputStream;             
  private boolean m_bRecording;                            
}

Passiamo ora al primo punto:

1) Il trasmittente stabilisce il formato dei dati dell'audio

Per semplicità mettiamo i parametri che definiscono il formato dell'audio in una classe con membri statici condivisi da AudioSender e AudioReceiver:

1
2
3
4
5
6
7
8
9
public class AudioConstants  
{  
  public static final AudioFormat.Encoding ENCODING = AudioFormat.Encoding.PCM_SIGNED;  
  public static int SAMPLESIZEINBITS = 16;  
  public static int CHANNELS = 2;  
  public static int FRAMESIZE = 4;  
  public static float FRAMERATE = (float)11025.0;  
  public static boolean ISBIGENDIAN = false;    
}

Per la definizione del formato dati dell'audio, Java mette a disposizione la classe AudioFormat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public void setupFormat()  
{  
  // Imposta il formato dell'audio  
  m_audioFormat = new AudioFormat(AudioConstants.ENCODING, 
    AudioConstants.SAMPLERATE,   
    AudioConstants.SAMPLESIZEINBITS,   
    AudioConstants.CHANNELS,   
    AudioConstants.FRAMESIZE,   
    AudioConstants.FRAMERATE,   
    AudioConstants.ISBIGENDIAN);  
}

2) Il trasmittente stabilisce una connessione internet con il ricevitore

Per questo usiamo la conosciutissima classe Socket. Usiamo la porta 4444 per la comunicazione:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public void establishConnection(String serverip )  
{  
  try {  
      m_callSocket = new Socket(serverip, 4444);  
  } catch (UnknownHostException e) {  
      System.err.println("Ip sconosciuto");  
      System.exit(1);  
  } catch (IOException e) {  
      System.err.println("Impossibile connttersi a " + serverip);  
      System.exit(1);  
  }

  try {  
    m_dataOutputStream = new DataOutputStream(m_callSocket.getOutputStream());  
  } catch (IOException e) {  
    System.err.println("Couldn't get output stream.");  
    System.exit(1);  
  }  
}

3) Il trasmittente cattura l'audio da una fonte.

Qui ci vorrebbe un processo che si collega alla scheda audio, esegue la cattura dei dati, e li mette in un buffer a disposizione per l'invio.

Le cose si complicano? No, perchè Java ci mette a disposizione un helper, AudioSystem, al quale si può richiedere un oggetto che sa già fare tutte queste cose. Basta invocare il metodo getLine():

1
2
3
4
5
public void createLine() throws LineUnavailableException  
{  
  m_targetLine = (TargetDataLine) AudioSystem.getLine(targetInfo);  
  m_targetLine.open(audioFormat, INTERNAL_BUFFER_SIZE);   
}

L'oggetto ritornato implementa l'interfaccia TargetDataLine, la quale fornisce tutte le operazioni necessarie alla cattura.

Occorre ora avviare la cattura e il processo di invio dei dati:

1
2
3
4
5
6
7
8
public void start()  
{  
  if (m_targetLine != null)  
  {  
    m_targetLine.start();  
    super.start();  
  }  
}

4) Il trasmittente invia l'audio al ricevitore

In questo modo avviamo sia il thread di cattura dell'audio (m_targetLine.start()) che il thread principale della classe AudioSender. Avviando il thread viene attivato il metodo run:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void run()  
{  
  if (m_dataOutputStream == null || m_targetLine == null)  
  {  
    System.err.println("socket o linea non inizializzate");  
    System.exit(1);  
  }

  byte[] abBuffer = new byte[INTERNAL_BUFFER_SIZE];  
  int nBufferSize = abBuffer.length;  
  m_bRecording = true;

  try  
  {  
    while (m_bRecording)  
    {  
      int nBytesRead = m_targetLine.read(abBuffer, 0, nBufferSize);  
      m_dataOutputStream.write(abBuffer, 0, nBytesRead);      
    }

    m_targetLine.flush();  
    m_targetLine.close();  
    m_dataOutputStream.close();  
    m_callSocket.close();  
  }

  catch (IOException e)  
  {  
    e.printStackTrace();  
    System.exit(1);  
  }  
}

run() è il cuore del lavoro di cattura e di invio dei dati. Il tutto si riduce ad una operazione di lettura dei dati audio catturati da m_targetLine ( m_targetLine.read() ), e all'invio di questi dati al socket ( m_dataOutputStream.write() ).

Per concludere, definiamo un metodo con il quale è possibile fermare il lavoro di cattura ed invio e dei dati...:

1
2
3
4
public void stopLine()  
{  
  m_bRecording = false;  
}

Scriviamo la main():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public static void main(String[] args) throws IOException   
{  
  if (args.length != 1)  
  {  
    printUsageAndExit();  
  }

  String serverip = args[0];

  AudioSender audioSender = new AudioSender();

  System.out.println("Imposta il formato dell'audio...");  
  audioSender.setupFormat();

  System.out.println("Connessione server...");  
  audioSender.establishConnection(serverip);

  System.out.println("Apre la linea audio...");  
  try   
  {  
    audioSender.createLine();  
  }  
  catch (LineUnavailableException e)  
  {  
    e.printStackTrace();  
    System.exit(1);  
  }

  System.out.println("Inizio streaming...");       
  audioSender.start();

  System.out.println("Premi ENTER per interrompere lo streaming.");  
  try  
  {  
    System.in.read();  
  }  
  catch (IOException e)  
  {  
    e.printStackTrace();  
  }

  System.out.println("Chiude la linea.");  
  audioSender.stopLine();  
}

private static void printUsageAndExit()  
{  
  System.out.println("AudioSender - uso:");  
  System.out.println("\tjava AudioSender <ip del server>");  
  System.exit(1);  
}

Abbiamo così concluso la prima parte, quella della cattura e dell'invio dei dati. Passiamo ora alla ricezione.

5) Il ricevitore, prende l'audio e lo memorizza in un buffer

Prima di iniziare a ricevere l'audio, AudioReceiver deve effettuare le stesse inizializzazioni di AudioSender. Deve quindi:

  • Inizializzare il formato dell'audio
  • Aprire un socket in ricezione per connettersi ad AudioSender
  • Avviare il thread di riproduzione dell'audio

Definiamo la classe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.net.*;  
import java.io.*;  
import javax.sound.sampled.AudioFormat;  
import javax.sound.sampled.AudioSystem;  
import javax.sound.sampled.DataLine;  
import javax.sound.sampled.LineUnavailableException;  
import javax.sound.sampled.SourceDataLine;

public class AudioReceiver  
{  
  private static final int INTERNAL_BUFFER_SIZE = 128000;  
  private ServerSocket m_serverSocket;  
  private Socket m_clientSocket;  
  private DataInputStream m_dataInputStream;  
  private AudioFormat m_audioFormat;  
  private SourceDataLine m_line;  
   
  public AudioReceiver  
  {  
    m_serverSocket = null;  
    m_clientSocket = null;  
    m_dataInputStream = null;  
    m_audioFormat = null;  
    m_line = null;  
  }  
    }

Definiamo metodo di setup del formato dell'audio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public void setupFormat()  
{  
  m_audioFormat = new AudioFormat(AudioConstants.ENCODING,  
    AudioConstants.SAMPLERATE,  
    AudioConstants.SAMPLESIZEINBITS,  
    AudioConstants.CHANNELS,  
    AudioConstants.FRAMESIZE,  
    AudioConstants.FRAMERATE,   
    AudioConstants.ISBIGENDIAN);  
}

Stabiliamo una connessione con il trasmittente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public void acceptConnection()  
{  
  // Crea il socket...  
  try {  
    m_serverSocket = new ServerSocket(4444);  
  } catch (IOException e) {  
    System.err.println("Impossibile ascoltare sulla porta: 4444.");  
    System.exit(1);  
  }  
    
  // In attesa della connessione...  
  try {  
    m_clientSocket = m_serverSocket.accept();  
  } catch (IOException e) {  
    System.err.println("Accept Fallito.");  
    System.exit(1);  
  }  
    
  // Chiamata ricevuta  
  try {  
    m_dataInputStream = new DataInputStream(m_clientSocket.getInputStream());    
  } catch (IOException e) {  
    System.err.println("Impossibile aprire input stream.");  
    System.exit(1);        
  }  
}

#!java
public void createLine() throws LineUnavailableException  
{  
  DataLine.Info targetInfo = new DataLine.Info(SourceDataLine.class, 
    m_audioFormat);    
  m_line = (SourceDataLine) AudioSystem.getLine( targetInfo );  
  m_line.open( m_audioFormat );  
}

6) Il ricevitore preleva i dati dal buffer e li riproduce alla velocità e alla qualità definita.

Attiviamo il lavoro di ricezione e riproduzione dell'audio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void receiveAndPlayAudio() throws IOException  
{  
  m_line.start();

  int nBytesRead = 0;  
  int cycle = 0;

  byte[] abData = new byte[INTERNAL_BUFFER_SIZE];    
  while (nBytesRead != -1)  
  {  
    nBytesRead = m_dataInputStream.read(abData, 0, abData.length);

    if (nBytesRead >= 0)  
    {  
      m_line.write(abData, 0, nBytesRead);  
    }  
  }

  m_line.drain();  
  m_line.close();    
    
  m_clientSocket.close();  
  m_serverSocket.close();    
}

Ed infine la main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void main(String[] args) throws IOException   
{  
  AudioReceiver audioReceiver = new AudioReceiver();  
  audioReceiver.setupFormat();

  System.out.println("In ascolto sulla porta 4444...");   
  audioReceiver.acceptConnection();

  System.out.println("Chiamata ricevuta...");

  try
  {  
    audioReceiver.createLine();  
  }  
  catch (LineUnavailableException e)  
  {  
    e.printStackTrace();  
    System.exit(1);  
  }

  System.out.println("Riproduzione audio...");   
  audioReceiver.receiveAndPlayAudio();

  System.out.println("Riproduzione terminata.");    
}

Ecco quindi che con poche linee di codice abbiamo scritto il nostro programma voip, completo di client e server.

Per provarlo, aprite due prompt DOS (digitando CMD nella finestra Start -> Esegui). Nel primo lanciate il programma ricevitore, ovvero il programma che riceve l'audio e lo riproduce:

Nel secondo lanciate il trasmittente, specificando come parametro l'indirizzo della macchina sul quale il programma ricevitore è in esecuzione. Nel nostro esempio stiamo lanciando entrambi i programmi sulla stessa macchina, quindi l'indirizzo del server è localhost. Lo stesso test naturalmente può essere effettuato su due qualsiasi pc connessi ad internet dotati di indirizzo pubblico:

A questo punto se parlate nel microfono dovreste iniziare a sentire la vostra voce, con qualche attimo di ritardo. Potete controllare nella finestra del programma ricevente l'avvenuto inizio dello streaming:

L'audio viene catturato ed inviato in formato "raw", senza nessuna compressione, ed è a questo che è dovuto l'attimo di ritardo con cui sentite la riproduzione.

E' possibile aumentare l'efficienza (diminuire il ritardo) usando la compressione A-LAW o U-LAW, i cui encoder e decoder sono inclusi nella libreria.

Non è particolarmente difficile integrare altri algoritmi di compressione ancora più efficienti, come ad esempio la compressione GSM. L'integrazione di un algoritmo di compressione esterno potrebbe essere l'argomento di un prossimo articolo di questo blog.

Comments