やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

TCP経由で音声(mp3)を配信してクライアントで再生させてみた

以前、システムコールを利用して簡単なTCPサーバーを実装しました。

www.okb-shelf.work

hello worldという文字列が返るだけのジンプルな実装になっていましたが、他にも音声や動画といったデータをTCPを通じて送れるのか気になりました(...送れると思います)。
現代ではYouTubeやSpotifyといった音声・動画を配信するサービスが多くなりましたが、同じようなことが自分の実装で可能なのでしょうか。 今回は実装したTCPサーバーを拡張して音声ファイルを配信してみようと思います。

できたもの

コード量が増えてきたのでGithubにて公開しました。
前回と同じようにサーバー側とクライアント側で順に紹介していきます。

github.com

サーバー側

意外にもあっさりと実装できました。
前回はhello worldという文字列からバイナリ型の値を作成して配信したのですが、この箇所を読み取った音声ファイル(.mp3)のバイナリを配信するようにしたのみです。ただ、一度に音声ファイルのデータをまとめて送るとパケットの容量が肥大化してしまいますし、TCPのデータ容量の最大値を超過してしまう可能性が考えられます。
そのため、取得したファイルのポインター経由で少しずつ(1024byte)バイナリを読み取って、複数回に分割して配信するようにしました。

func handleClient(clientFd int) {
    buffer := make([]byte, 1024)
    soundFile, err := os.Open("./static/birdland1.mp3")
    if err != nil {
        fmt.Println("[ERROR] Failed Open file: ", err)
        os.Exit(1)
    }
    defer soundFile.Close()

    for {
        n, err := soundFile.Read(buffer)
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println("[ERROR] Failed Read buffer: ", err)
            return
        }
        if _, err := syscall.Write(clientFd, buffer[:n]); err != nil {
            fmt.Println("[ERROR] Failed Write buffer to file descriptor: ", err)
            return
        }
    }

    if _, err := syscall.Write(clientFd, []byte("EOF")); err != nil {
        fmt.Println("[ERROR] Failed Write 'EOF' to file descriptor: ", err)
        os.Exit(1)
    }
    syscall.Close(clientFd)
}

socket-challenge/main.go at send-mp3 · okabe-yuya/socket-challenge · GitHub

クライアント側で読み取り終了を判定するために、最後にEOFというバイナリを送るようにしました。
また、本当はサーバー起動時に一度だけ音声ファイルを読み込んで、配信する方がメモリに優しいとは思うのですが複数クライアントが接続してくることを考慮する必要があり、Mutexなどを利用して排他制御をしてSeekさせるのが面倒だったので、Accept後に起動するgoroutineの内部でファイルを読み込む方式を採用しました。
悔しい...。

クライアント側

サーバー側の変更に伴ってクライアント側も変更しました。
前回とは異なり、音声ファイル全体を受信する必要があるためEOFの値が取得されるまで、繰り返し受信するようにしました。 取得したバイナリは文字例として結合させています。

# frozen_string_literal: true

require 'socket'
require 'securerandom'

port = 8001

s = TCPSocket.open('127.0.0.1', port)
buffer = ''

puts ':::Start receive binary data'
binary = s.gets
until binary == 'EOF'
  binary = s.gets
  break if binary.nil?

  buffer += binary
end

s.close

音声の再生

このままバイナリから音声を再生できれば嬉しいのですが、プログラムからバイナリを読み込んで音声を再生させるのは非常に難しいです。ライブラリを使うことも検討しましたが、以前、マクドナルドのポテトのティロリ音を再生する際に使用したafplayコマンドを使用しました。

qiita.com

一度、バイナリをファイルに書き出してafplayを実行後、ファイルを削除するようになっています。
本当はファイルに書き出したくないのですが諦めました。

puts ':::Received binary data'
file_name = "#{SecureRandom.uuid}_received_se.mp3"
File.open(file_name, 'w') do |f|
  f.write(buffer)
end

puts ':::Play sound'
system('afplay', file_name)
File.delete(file_name)

socket-challenge/client.rb at send-mp3 · okabe-yuya/socket-challenge · GitHub

実行結果

サーバー側を起動しておきます。

$ go run main.go 
Server is listening on 0.0.0.0:8001

この状態で、クライアント側を起動します。

$ ruby client.rb 
hello world
hello world
hello world
hello world
hello world

音声が再生されていることを確認するため、実行結果を録画しました。
無事に音声が再生されました。すごい!楽しい!

youtu.be

最後に

今回の実装を通して個人的には非常に驚かされました。
というのも、分割して受信したバイナリをクライアント側で結合したものが、音声として問題なく再生できたという点です。 つまり、基本的にバイナリとして送信できれば、ファイルの形式は問わないということでしょうか。
音声ファイルを受信・再生できたので、次は動画ファイルを受信・再生できるように挑戦しようと思います。

少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。