iOS Airplay--Airtunes音乐播放在Android盒子和手机上的实现 (终结篇)

在上一篇,我们让iOS设备通过AirTunes连接上了Android设备链接
这一篇,我们将完成iOS设备通过AirTunes把音乐推给Android设播放。

四、实现Android设备播放AirTunes音乐

- 1 对RaopRtsPipelineFactory的pipeline 构造完整的handler处理,新增了一个最核心的handler--RaopAudioHandler
public class RaopRtsPipelineFactory implements ChannelPipelineFactory {
    @Override
    public ChannelPipeline getPipeline() throws Exception {

        final ChannelPipeline pipeline = Channels.pipeline();
        //因为是管道 注意保持正确的顺序

        //构造executionHanlder 和关闭executionHanlder
        final AirTunesRunnable airTunesRunnable = AirTunesRunnable.getInstance();
        pipeline.addLast("exectionHandler", airTunesRunnable.getChannelExecutionHandler());
        pipeline.addLast("closeOnShutdownHandler", new SimpleChannelUpstreamHandler(){
            @Override
            public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception {
                airTunesRunnable.getChannelGroup().add(e.getChannel());
                super.channelOpen(ctx, e);
            }
        });

        //add exception logger
        pipeline.addLast("exceptionLogger", new ExceptionLoggingHandler());

        //rtsp decoder & encoder
        pipeline.addLast("decoder", new RtspRequestDecoder());
        pipeline.addLast("encoder", new RtspResponseEncoder());

        //rstp logger and errer response
        pipeline.addLast("logger", new RtspLoggingHandler());
        pipeline.addLast("errorResponse", new RtspErrorResponseHandler());

        //app airtunes need
        pipeline.addLast("challengeResponse", new RaopRtspChallengeResponseHandler(NetworkUtils.getInstance().getHardwareAddress()));
        pipeline.addLast("header", new RaopRtspHeaderHandler());
        //let iOS devices know server support methods
        pipeline.addLast("options", new RaopRtspOptionsHandler());

        //!!!Core handler audioHandler
        pipeline.addLast("audio", new RaopAudioHandler(airTunesRunnable.getExecutorService()));

        //unsupport Response
        pipeline.addLast("unsupportedResponse", new RtspUnsupportedResponseHandler());


        return pipeline;
    }
}
- 2 RaopAudioHandler的处理流程:ANNOUNCE(标识链接,更新客户端session),SETUP(构造连接),RECORD(记录保存媒体数据),FLUSH(当airtunes中断时,清空里面的数据),TEARDOWN(关闭连接)。
@Override
    public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception {
        final HttpRequest req = (HttpRequest)evt.getMessage();
        final HttpMethod method = req.getMethod();

        LOG.info("messageReceived : HttpMethod: " + method);
        
        if (RaopRtspMethods.ANNOUNCE.equals(method)) {
            announceReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.SETUP.equals(method)) {
            setupReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.RECORD.equals(method)) {
            recordReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.FLUSH.equals(method)) {
            flushReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.TEARDOWN.equals(method)) {
            teardownReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.SET_PARAMETER.equals(method)) {
            setParameterReceived(ctx, req);
            return;
        }
        else if (RaopRtspMethods.GET_PARAMETER.equals(method)) {
            getParameterReceived(ctx, req);
            return;
        }

        super.messageReceived(ctx, evt);
    }

A. AUNOUNCE处理。announce在传输的时候遵循了SDP协议。SDP协议用来描述媒体信息。AirTunes协议的样式如下:

/**
         * Sample sdp content:
         * 
            v=0
            o=iTunes 3413821438 0 IN IP4 fe80::217:f2ff:fe0f:e0f6
            s=iTunes
            c=IN IP4 fe80::5a55:caff:fe1a:e187
            t=0 0
            m=audio 0 RTP/AVP 96
            a=rtpmap:96 AppleLossless
            a=fmtp:96 352 0 16 40 10 14 2 255 0 0 44100
            a=fpaeskey:RlBMWQECAQAAAAA8AAAAAPFOnNe+zWb5/n4L5KZkE2AAAAAQlDx69reTdwHF9LaNmhiRURTAbcL4brYAceAkZ49YirXm62N4
            a=aesiv:5b+YZi9Ikb845BmNhaVo+Q
         */

对协议进行解析:

//go through each line and parse the sdp parameters
for(final String line: sdp.split("\n")) {
    /* Split SDP line into attribute and setting */
    final Matcher lineMatcher = s_pattern_sdp_line.matcher(line);

    if ( ! lineMatcher.matches()){
        throw new ProtocolException("Cannot parse SDP line " + line);
    }

    final char attribute = lineMatcher.group(1).charAt(0);
    final String setting = lineMatcher.group(2);

    /* Handle attributes */
    switch (attribute) {
        case 'm':
            /* Attribute m. Maps an audio format index to a stream */
            final Matcher m_matcher = s_pattern_sdp_m.matcher(setting);
            if (!m_matcher.matches())
                throw new ProtocolException("Cannot parse SDP " + attribute + "'s setting " + setting);
            audioFormatIndex = Integer.valueOf(m_matcher.group(2));
            break;

        case 'a':
            LOG.info("setting: " + setting);

            /* Attribute a. Defines various session properties */
            final Matcher a_matcher = s_pattern_sdp_a.matcher(setting);

            if ( ! a_matcher.matches() ){
                throw new ProtocolException("Cannot parse SDP " + attribute + "'s setting " + setting);
            }

            final String key = a_matcher.group(1);
            final String value = a_matcher.group(2);

            if ("rtpmap".equals(key)) {
                /* Sets the decoder for an audio format index */
                final Matcher a_rtpmap_matcher = s_pattern_sdp_a_rtpmap.matcher(value);
                if (!a_rtpmap_matcher.matches())
                    throw new ProtocolException("Cannot parse SDP " + attribute + "'s rtpmap entry " + value);

                final int formatIdx = Integer.valueOf(a_rtpmap_matcher.group(1));
                final String format = a_rtpmap_matcher.group(2);
                if ("AppleLossless".equals(format))
                    alacFormatIndex = formatIdx;
            }
            else if ("fmtp".equals(key)) {
                /* Sets the decoding parameters for a audio format index */
                final String[] parts = value.split(" ");
                if (parts.length > 0)
                    descriptionFormatIndex = Integer.valueOf(parts[0]);
                if (parts.length > 1)
                    formatOptions = Arrays.copyOfRange(parts, 1, parts.length);
            }
            else if ("rsaaeskey".equals(key)) {
                /* Sets the AES key required to decrypt the audio data. The key is
                 * encrypted wih the AirTunes private key
                 */
                byte[] aesKeyRaw;

                rsaPkCS1OaepCipher.init(Cipher.DECRYPT_MODE, AirTunesCryptography.PrivateKey);
                aesKeyRaw = rsaPkCS1OaepCipher.doFinal(Base64.decodeUnpadded(value));

                aesKey = new SecretKeySpec(aesKeyRaw, "AES");
            }
            else if ("aesiv".equals(key)) {
                /* Sets the AES initialization vector */
                aesIv = new IvParameterSpec(Base64.decodeUnpadded(value));
            }
            break;

        default:
            /* Ignore */
            break;
    }
}

*通过AES 解密的 秘钥 和 初始化矩阵IV 以及流的数据格式,从而初始化 ALAC Decoder *

B. SETUP处理。 SETUP就是iOS设备和我们信息交换:主要是三个 port 的信息,对应三个 channel。分别是 control port -> control channel , timing port -> timing channel 和 server port -> audio channel ,这是三个 UDP 连接 的端口。这也是整个 Airtunes 服务结构核心部分。

  • control port 是用来发送 resendTransmitRequest 的 channel,也就是当 Android 这边发现我收到的音乐流数据包中有丢失帧的时候,可以通过 control port 发送 resendTransmit 的 request 给 iOS 设备,设备收到后会将帧在 response 中补发回来。
  • timing port 用来传输 Airplay 的时间同步包,同时也可以主动向 iOS 设备请求当前的时间戳来校准流的时间戳。
  • server port 则是用来传输最主要的音乐流数据包。
  • 对于这三个端口,我们同样建立了netty server和 pipelinefactory

协议解析:对指定几个 key 进行 response ,其中 interleaved 和 mode 返回的是固定参数, control_port 和 timing_port 在 request 中所对应的 value 是客户端的端口,而 response 中需要带上服务端的端口。同时,这两个 UDP 连接由服务端发起去连接客户端对应的端口。最后再告知客户端 server_port 的端口。

for(final String requestOption: requestOptions) {
    /* Split option into key and value */
    final Matcher transportOption = PATTERN_TRANSPORT_OPTION.matcher(requestOption);
    if ( ! transportOption.matches() ){
        throw new ProtocolException("Cannot parse Transport option " + requestOption);
    }
    final String key = transportOption.group(1);
    final String value = transportOption.group(3);

    if ("interleaved".equals(key)) {
        /* Probably means that two channels are interleaved in the stream. Included in the response options */
        if ( ! "0-1".equals(value)){
            throw new ProtocolException("Unsupported Transport option, interleaved must be 0-1 but was " + value);
        }
        responseOptions.add("interleaved=0-1");
    }
    else if ("mode".equals(key)) {
        /* Means the we're supposed to receive audio data, not send it. Included in the response options */
        if ( ! "record".equals(value)){
            throw new ProtocolException("Unsupported Transport option, mode must be record but was " + value);
        }
        responseOptions.add("mode=record");
    }
    else if ("control_port".equals(key)) {
        /* Port number of the client's control socket. Response includes port number of *our* control port */
        final int clientControlPort = Integer.valueOf(value);

        controlChannel = createRtpChannel(
            substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 53670),
            substitutePort((InetSocketAddress)ctx.getChannel().getRemoteAddress(), clientControlPort),
            RaopRtpChannelType.Control
        );

        LOG.info("Launched RTP control service on " + controlChannel.getLocalAddress());

        responseOptions.add("control_port=" + ((InetSocketAddress)controlChannel.getLocalAddress()).getPort());
    }
    else if ("timing_port".equals(key)) {
        /* Port number of the client's timing socket. Response includes port number of *our* timing port */
        final int clientTimingPort = Integer.valueOf(value);

        timingChannel = createRtpChannel(
            substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 53669),
            substitutePort((InetSocketAddress)ctx.getChannel().getRemoteAddress(), clientTimingPort),
            RaopRtpChannelType.Timing
        );

        LOG.info("Launched RTP timing service on " + timingChannel.getLocalAddress());

        responseOptions.add("timing_port=" + ((InetSocketAddress)timingChannel.getLocalAddress()).getPort());
    }
    else {
        /* Ignore unknown options */
        responseOptions.add(requestOption);
    }
}
- 3 在setup执行后,整个Airtunes的通信图示**

(1)UpStream:数据进入 pipeline 之后,按照 RTP Packet 的格式进行 decode。在 Airplay 协议中,总共有如下几种

  • Packet Type:
    TimingRequest [timing channel]
    TimingResponse [timing channel]
    Sync [timing channel]
    RetransmitRequest [control channel]
    AudioRetransmit [audio channel]
    AudioTransmit [audio channel]

  • timing channel 在 Sync 数据的同事,开启单独的线程每三秒钟执行一次 timing request,来确认本地时钟和客户端时钟的同步。control channel 每收到一个 新的 audio 数据包的时候都会 确认一次数据包的 sequence number 是否和当前的是连续的 ,如果不连续的,则将中间缺失的 number 标记为 missing 的数据包,并且向客户端发送一个 resend 的请求。当客户端发来了 AudioRetransmit 类型的数据包后,由 audio channel 接收的,control channel 只是负责将刚才标记为 missing 的 sequence number 清除掉。

  • 这两个 channel 在发送 request 的时候,也会发回到 audio channel 的 Handler 上来,通过 audio channel 这边的 encode 之后再发送出去。

  • 而音乐数据包,则需要经过 AES 解密,这个解密器我们已经在 ANNOUNCE 的时候初始化好了,再经过 ALACDecoder,也是在 ANNOUNCE 的时候根据获得的媒体信息初始化的音频解码器,最后在 EnqueueHandler 中决定是否进入音频输出队列。

(2)Down Stream: timing channel 和 control channel channel 负责向客户端发送具体的请求。

- 4 运行工程到Android设备上,在iOS通过AirTunes找到"RDuwan-Airtunes",连接上设备,打开iOS上的音乐软件(比如QQ音乐),即可以在Android设备上成功听到了音乐的播放。
- 5 完整工程见github链接
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容