2017年3月16日木曜日

AndroidでWindowsの音声だけを(概ね)遅滞なく再生させたい。(3)

前承

今回はAndroid側でPCMデータを受信して再生するアプリを作成します。
といっても、Android側で用意されているライブラリが優れているので、アクティビティ、ソケット接続・受信スレッド、PCM再生スレッドの三本を用意すればそれでおしまいです。
一応、手順を確認します。
  1. connectする
  2. 再生形式を受信してAudioTrackを初期化する
  3. PCMデータを受信する
  4. 切断するまで3に戻る
  5. 1に戻る
これだけです。

さて、まず、ソケット接続・受信スレッドクラスです。
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
public class PCMDownloader extends Thread {
 PlayAudio m_pa;
 Socket s;
 boolean m_bIsForceStop = false;
 String m_hostaddr = "";
 String m_errstr = "";
 
 public PCMDownloader( PlayAudio pa ) {
  m_pa = pa;
 }
 
 int readall( DataInputStream dis, byte[] buf, int len ) throws IOException {
  int spos = 0;
  int remain = len;
  int readlen = 0;
  while( true ){
   try{
    readlen = dis.read( buf, spos, remain );
    if( readlen < 0 ){
     // closed
     return -1;
    }
    if( remain - readlen <= 0 ){
     break;
    }
    spos += readlen;
    remain -= readlen;
   }
   catch( SocketException e ){
    if( m_bIsForceStop == false ){
     m_errstr = e.getMessage();
    }
   }
   catch( Exception e ){
    StringWriter sw = new StringWriter();
    PrintWriter pw = new PrintWriter( sw );
    e.printStackTrace( pw );
    pw.flush();
    String str = sw.toString();
    System.out.println( "Exception: " + e + "TRACE:" + str );
    m_errstr = str;
    return -1;
   }
  }
  return 0;
 }
 
 static int byte2DWORD( byte[] bytes, int startpos ) {
  return ( ( (short)bytes[ startpos ] ) & 0xFF ) << 24
      | ( ( (short)bytes[ startpos + 1 ] ) & 0xFF ) << 16
      | ( ( (short)bytes[ startpos + 2 ] ) & 0xFF ) << 8
      | ( ( (short)bytes[ startpos + 3 ] ) & 0xFF );
 }
 
 static int byte2WORD( byte[] bytes, int startpos ) {
  return ( ( (short)bytes[ startpos ] ) & 0xFF ) << 8
      | ( ( (short)bytes[ startpos + 1 ] ) & 0xFF );
 }
 
 public void recv( Socket s ) throws IOException {
  byte[] recvbuf = new byte[ 1024 * 1024 ];
  InputStream is = s.getInputStream();
  DataInputStream dis = new DataInputStream( is );
 
  while( true ){
   int ret = readall( dis, recvbuf, 24 );
   if( ret < 0
       || recvbuf[ 0 ] != 'F'
       || recvbuf[ 1 ] != '*'
       || recvbuf[ 2 ] != 'C'
       || recvbuf[ 3 ] != 'K' ){
 
    dis.close();
    s.close();
    break;
   }
   Calendar c = SystemTime2Calendar( recvbuf, 4 );
 
   // JAVA大嫌い。バカみたいなコード書かされる.byte型のくせに符号付だよ馬鹿か
   int wavdatasize = byte2DWORD( recvbuf, 20 );
 
   if( readall( dis, recvbuf, wavdatasize ) < 0 ){
    dis.close();
    s.close();
    break;
   }
   m_pa.addData( c, recvbuf, wavdatasize );
  }
 }
 
 static Calendar SystemTime2Calendar( byte[] bytes, int startpos ) {
  Calendar c = Calendar.getInstance( TimeZone.getTimeZone( "UTC" ) );
  c.clear();
  c.set( byte2WORD( bytes, startpos ),
      byte2WORD( bytes, startpos + 2 ) - 1,
      byte2WORD( bytes, startpos + 6 ),
      byte2WORD( bytes, startpos + 8 ),
      byte2WORD( bytes, startpos + 10 ),
      byte2WORD( bytes, startpos + 12 ) );
  c.set( Calendar.MILLISECOND, byte2WORD( bytes, startpos + 14 ) );
  return c;
 }
 
 public void run() {
  init();
  loop();
 }
 
 public void setHostAddr( String s ) {
  m_hostaddr = s;
 }
 
 public void forcestop() {
  try{
   m_bIsForceStop = true;
   s.close();
  }
  catch( Exception e ){
   // 無視
  }
  m_errstr = "disconnected";
 }
 
 public boolean recvWaveData( Socket s ) throws IOException {
  InputStream is = s.getInputStream();
  DataInputStream dis = new DataInputStream( is );
  byte[] recvbuf = new byte[ 12 ];
  int ret = readall( dis, recvbuf, 12 );
  boolean bret = false;
  if( ret == 0
      && recvbuf[ 0 ] == 'f'
      && recvbuf[ 1 ] == '*'
      && recvbuf[ 2 ] == 'c'
      && recvbuf[ 3 ] == 'k' ){
   int wBitsPerSample;
   int nChannels;
   int nSamplesPerSec;
   wBitsPerSample = byte2WORD( recvbuf, 4 );
   nChannels = byte2WORD( recvbuf, 6 );
   nSamplesPerSec = byte2DWORD( recvbuf, 8 );
   m_pa.init( wBitsPerSample, nChannels, nSamplesPerSec );
   bret = true;
  }
  return bret;
 }
 
 public void loop() {
  while( m_bIsForceStop == false ){
   try{
    int port = 1234;
    s = new Socket( m_hostaddr, port );
    m_errstr = "connected to " + m_hostaddr + ":" + port;
    if( recvWaveData( s ) == false ){
     s.close();
     continue;
    }
 
    recv( s );
   }
   catch( Exception e ){
    // System.out.println("Exception: " + e + e.getStackTrace());
    StringWriter sw = new StringWriter();
    PrintWriter pw = new PrintWriter( sw );
    e.printStackTrace( pw );
    pw.flush();
    String str = sw.toString();
    m_errstr = str;
    System.out.println( "Exception: " + e + "TRACE:" + str );
   }
  }
 }
 
 void init()
 {
  m_errstr = "";
  m_bIsForceStop = false;
 }
}
こんだけ。 次に音声再生スレッドです。
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
public class PlayAudio extends Thread {
 class PlayData {
  byte[] buf;
  int len;
  long millisec;
 }
 
 public long m_latency;
 private AudioTrack mAudioTrack = null;
 List<playdata> m_list;
 
 synchronized int getListCount() {
  return m_list == null ? 0 : m_list.size();
 }
 
 synchronized void addData( Calendar c, byte[] buf, int len ) {
  PlayData pd = new PlayData();
  pd.buf = new byte[ len ];
  System.arraycopy( buf, 0, pd.buf, 0, len );
  pd.len = len;
  if( m_list == null ){
   m_list = new ArrayList<playdata>();
  }
  pd.millisec = c.getTimeInMillis();
  m_list.add( pd );
  notify();
 }
 
 synchronized List<playdata> getAllData() throws InterruptedException {
  wait();
  List<playdata> retlist = m_list;
  m_list = null;
  return retlist;
 }
 
 synchronized PlayData getData() throws InterruptedException {
  if( m_list == null || m_list.isEmpty() == true ){
   wait();
  }
  PlayData pd = m_list.get( 0 );
  m_latency = Calendar.getInstance( TimeZone.getTimeZone( "UTC" ) )
      .getTimeInMillis() - pd.millisec;
  m_list.remove( 0 );
  return pd;
 }
 
 synchronized void clearData() {
  if( m_list != null ){
   if( m_list.isEmpty() == false ){
    m_list.clear();
   }
   m_list = null;
  }
 }
 
 public void run()
 {
  init( 16, 2, 48000 );
  try{
   loop();
  }
  catch( InterruptedException e ){
   // ignore
  }
  catch( Exception e ){
   StringWriter sw = new StringWriter();
   PrintWriter pw = new PrintWriter( sw );
   e.printStackTrace( pw );
   pw.flush();
   String str = sw.toString();
   System.out.println( "Exception: " + e + "TRACE:" + str );
  }
 }
 
 void loop() throws InterruptedException {
  while( true ){
   PlayData pd = getData();
   mAudioTrack.write( pd.buf, 0, pd.len );
  }
 }
 
 public void init( int wBitsPerSample, int nChannels, int nSamplesPerSec ) {
  clearData();
  int bit;
  switch( wBitsPerSample ){
   case 8:
    bit = AudioFormat.ENCODING_PCM_8BIT;
    break;
   default:
    bit = AudioFormat.ENCODING_PCM_16BIT;
    break;
  }
  int ch = AudioFormat.CHANNEL_OUT_STEREO;
  switch( nChannels ){
   case 1:
    ch = AudioFormat.CHANNEL_OUT_MONO;
    break;
   case 2:
    ch = AudioFormat.CHANNEL_OUT_STEREO;
    break;
  }
  int minBufferSizeInBytes = AudioTrack.getMinBufferSize(
      nSamplesPerSec,
      ch,
      bit );
  try{
   if( mAudioTrack != null ){
    mAudioTrack.stop();
    mAudioTrack.flush();
   }
  }
  catch( Exception e ){
   // ignore
  }
 
  mAudioTrack = new AudioTrack(
      AudioManager.STREAM_MUSIC,
      nSamplesPerSec,
      ch,
      bit,
      minBufferSizeInBytes,
      AudioTrack.MODE_STREAM );
 
  mAudioTrack.play();
 }
}
これまたこんだけ。
AudioTrackクラスが優秀すぎてほとんどやることがありません。

Activityはこんな感じで。
1.レイアウト
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
52
53
54
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="${relativePackage}.${activityClass}" >
 
    <TextView
        android:id="@+id/textView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/hello_world" />
 
    <TextView
        android:id="@+id/textView_Queue"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignLeft="@+id/button1"
        android:layout_below="@+id/textView1"
        android:layout_marginTop="102dp"
        android:text="Medium Text"
        android:textAppearance="?android:attr/textAppearanceMedium" />
 
    <Button
        android:id="@+id/button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_below="@+id/textView_Queue"
        android:layout_marginLeft="40dp"
        android:layout_marginTop="39dp"
        android:onClick="onClickTestButton"
        android:text="Button" />
 
    <EditText
        android:id="@+id/editTextHost"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_below="@+id/textView1"
        android:layout_marginLeft="27dp"
        android:layout_marginTop="39dp"
        android:ems="10"
        android:text="pine" />
 
    <TextView
        android:id="@+id/textViewErrStr"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignLeft="@+id/button1"
        android:layout_below="@+id/button1"
        android:layout_marginTop="41dp"
        android:text="input hostname and run" />
 
</RelativeLayout>
2.javaコード
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
52
53
54
55
56
57
58
59
60
61
62
public class ActivityMain extends Activity {
 PCMDownloader m_pdl;
 PlayAudio m_pa;
 Button testButton;
 TextView textView_queue;
 TextView textView_errStr;
 EditText editText_hostAddr;
 boolean testButtonState = false;
 
 Handler handler = new Handler();
 Runnable runnable = new Runnable() {
  public void run() {
   try{
    textView_queue.setText( "Queue: " + m_pa.getListCount()
        + " Latency:" + m_pa.m_latency + "ms" );
    if( m_pdl != null ){
     textView_errStr.setText( m_pdl.m_errstr );
    }
   }
   catch( Exception e ){
    // 無視
   }
   handler.postDelayed( runnable, 1000 );
  }
 };
 
 @Override
 protected void onCreate( Bundle savedInstanceState ) {
  super.onCreate( savedInstanceState );
  setContentView( R.layout.activity_main );
 
  testButton = (Button)this.findViewById( R.id.button1 );
  testButton.setText( "run" );
 
  textView_queue = (TextView)this.findViewById( R.id.textView_Queue );
  textView_queue.setText( "Queue:0 ..." );
 
  textView_errStr = (TextView)this.findViewById( R.id.textViewErrStr );
  editText_hostAddr = (EditText)this.findViewById( R.id.editTextHost );
 
  m_pa = new PlayAudio();
  m_pa.start();
  runnable.run();
 }
 
 public void onClickTestButton( View view ) {
  if( testButtonState == false ){
   m_pdl = new PCMDownloader( m_pa );
 
   String addr = ( (SpannableStringBuilder)editText_hostAddr.getText() )
       .toString();
   m_pdl.setHostAddr( addr );
   m_pdl.start();
   testButton.setText( "stop" );
  }
  else{
   m_pdl.forcestop();
   testButton.setText( "run" );
  }
  testButtonState = !testButtonState;
 }
}
3.AndroidManifestはこんなんで。
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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.pinefield.pcmreceiver"
    android:versionCode="1"
    android:versionName="1.0" >
        <uses-permission android:name="android.permission.INTERNET"/>
        <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
         
    <uses-sdk
        android:minSdkVersion="17"
        android:targetSdkVersion="17" />
 
    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".ActivityMain"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
 
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
 
    </application>
 
</manifest>
あらやだ奥さん、単純明快過ぎて説明すべき部分がどこにもないじゃないの。

うーんと。

ええっと、わざと手を抜いているところがあって、Android側で重い処理やらが始まるとみるみる滞留した未再生データがたまるのがActivityから確認できます。
これをわざと音飛びさせてすっ飛ばす処理を最初は入れていたのですが、切断して再接続すれば済む話なので検証用にすっ飛ばす処理を抜きました。
滞留するときはものすごい勢いで溜まります。

また、2ch以下で16bitPCMデータしか再生できないコードになっています。

レイテンシの計算ですが、AndroidもPCも内蔵時計の時間のいい加減さは折り紙付きですので、下手をすると見た目では数千ミリ秒もの遅延が発生してるように見えたりしますが、単純に時計があってないだけです。

まあ、それはそれとして、順調にいくと確かに録音レイテンシ以上の遅延なく再生できます。
但し、通信帯域はそれなりに消費しますし、それ以上に電源を猛烈に食いつぶします。バッテリ駆動下での検証はお勧めしません。

んー。

ここまで聞いたけどだから何、って聞きたいんでしょう?
だからどうしたといわれても。

好きなんです、こういうの。

0 件のコメント:

コメントを投稿