diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..638b0d7 Binary files /dev/null and b/.DS_Store differ diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..c0afd46 --- /dev/null +++ b/.classpath @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86c056e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +target/ diff --git a/.project b/.project new file mode 100644 index 0000000..658ce45 --- /dev/null +++ b/.project @@ -0,0 +1,23 @@ + + + DroidAirPlay + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..93ff221 --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,6 @@ +#Mon Jun 18 20:52:19 NZST 2012 +eclipse.preferences.version=1 +encoding//src/main/java=UTF-8 +encoding//src/main/resources=UTF-8 +encoding//src/test/java=UTF-8 +encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..9b33ca9 --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,6 @@ +#Mon Jun 18 20:52:19 NZST 2012 +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6 +org.eclipse.jdt.core.compiler.compliance=1.6 +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.source=1.6 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..accef45 --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,5 @@ +#Mon Jun 18 20:51:15 NZST 2012 +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/README b/README index f06993d..d98a488 100644 --- a/README +++ b/README @@ -1,3 +1,20 @@ +DroidAirPlay +=========== +Rafael Almeida (rafael@iswe.co.nz) +July 2012 + +Overview +--------------- +The ideia is to develop a AirPlay Receiver that runs on Android tablet and is part of a wider range of apps for in-car-systems. +The ideia is that the tablet is used as a car dashboard to control all car functions and the ability to stream audio, video e fotos +from iOS devices is achieved throught this project. + +This project is based on the work done by Florian G. Pflug project's AirReceiver. + +AirReceiver is a desktop AirPlay receiver application, bellow is a full description: + +NOTE: Work on DroidAirPlay has just worked (as of June 2012) so most of the code and project strucutre is the same as AirReceiver (since this is a fork from AirReceiver's repository). + AirReceiver =========== Florian G. Pflug diff --git a/pom.xml b/pom.xml index f86aa59..84dc064 100644 --- a/pom.xml +++ b/pom.xml @@ -3,13 +3,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.phlo - AirReceiver - 1.2 + nz.co.iswe + DroidAirPlay + 0.1 jar - AirReceiver - https://github.com/fgp/AirReceiver + DroidAirPlay + https://github.com/pentateu/DroidAirPlay UTF-8 @@ -42,6 +42,11 @@ 4.8.2 test + + com.google.android + android + 4.0.1.2 + diff --git a/src/main/java/nz/co/iswe/android/airplay/AirPlayServer.java b/src/main/java/nz/co/iswe/android/airplay/AirPlayServer.java new file mode 100644 index 0000000..84c2388 --- /dev/null +++ b/src/main/java/nz/co/iswe/android/airplay/AirPlayServer.java @@ -0,0 +1,265 @@ +package nz.co.iswe.android.airplay; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceInfo; + +import nz.co.iswe.android.airplay.network.NetworkUtils; +import nz.co.iswe.android.airplay.network.raop.RaopRtspPipelineFactory; + +import org.jboss.netty.bootstrap.ServerBootstrap; +import org.jboss.netty.channel.ChannelHandler; +import org.jboss.netty.channel.group.ChannelGroup; +import org.jboss.netty.channel.group.ChannelGroupFuture; +import org.jboss.netty.channel.group.DefaultChannelGroup; +import org.jboss.netty.channel.socket.nio.NioServerSocketChannelFactory; +import org.jboss.netty.handler.execution.ExecutionHandler; +import org.jboss.netty.handler.execution.OrderedMemoryAwareThreadPoolExecutor; + +/** + * Android AirPlay Server Implementation + * + * @author Rafael Almeida + * + */ +public class AirPlayServer implements Runnable { + + private static final Logger LOG = Logger.getLogger(AirPlayServer.class.getName()); + + /** + * The AirTunes/RAOP service type + */ + static final String AIR_TUNES_SERVICE_TYPE = "_raop._tcp.local."; + + /** + * The AirTunes/RAOP M-DNS service properties (TXT record) + */ + static final Map AIRTUNES_SERVICE_PROPERTIES = map( + "txtvers", "1", + "tp", "UDP", + "ch", "2", + "ss", "16", + "sr", "44100", + "pw", "false", + "sm", "false", + "sv", "false", + "ek", "1", + "et", "0,1", + "cn", "0,1", + "vn", "3" + ); + + private static AirPlayServer instance = null; + public static AirPlayServer getIstance(){ + if(instance == null){ + instance = new AirPlayServer(); + } + return instance; + } + + /** + * Global executor service. Used e.g. to initialize the various netty channel factories + */ + protected ExecutorService executorService; + + /** + * Channel execution handler. Spreads channel message handling over multiple threads + */ + protected ExecutionHandler channelExecutionHandler; + + /** + * All open RTSP channels. Used to close all open challens during shutdown. + */ + protected ChannelGroup channelGroup; + + /** + * JmDNS instances (one per IP address). Used to unregister the mDNS services + * during shutdown. + */ + protected List jmDNSInstances; + + /** + * The AirTunes/RAOP RTSP port + */ + private int rtspPort = 5000; //default value + + private AirPlayServer(){ + //create executor service + executorService = Executors.newCachedThreadPool(); + + //create channel execution handler + channelExecutionHandler = new ExecutionHandler(new OrderedMemoryAwareThreadPoolExecutor(4, 0, 0)); + + //channel group + channelGroup = new DefaultChannelGroup(); + + //list of mDNS services + jmDNSInstances = new java.util.LinkedList(); + } + + public int getRtspPort() { + return rtspPort; + } + + public void setRtspPort(int rtspPort) { + this.rtspPort = rtspPort; + } + + public void run() { + + startService(); + } + + private void startService() { + /* Make sure AirPlay Server shuts down gracefully */ + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + @Override + public void run() { + onShutdown(); + } + })); + + LOG.info("VM Shutdown Hook added sucessfully!"); + + /* Create AirTunes RTSP server */ + final ServerBootstrap airTunesRtspBootstrap = new ServerBootstrap(new NioServerSocketChannelFactory(executorService, executorService)); + airTunesRtspBootstrap.setPipelineFactory(new RaopRtspPipelineFactory()); + airTunesRtspBootstrap.setOption("reuseAddress", true); + airTunesRtspBootstrap.setOption("child.tcpNoDelay", true); + airTunesRtspBootstrap.setOption("child.keepAlive", true); + + try { + channelGroup.add(airTunesRtspBootstrap.bind(new InetSocketAddress(Inet4Address.getByName("0.0.0.0"), getRtspPort()))); + } + catch (UnknownHostException e) { + LOG.log(Level.SEVERE, "Failed to bind RTSP Bootstrap on port: " + getRtspPort(), e); + } + + LOG.info("Launched RTSP service on port " + getRtspPort()); + + //get Network details + NetworkUtils networkUtils = NetworkUtils.getInstance(); + + String hostName = networkUtils.getHostUtils(); + String hardwareAddressString = networkUtils.getHardwareAddressString(); + + try { + /* Create mDNS responders. */ + synchronized(jmDNSInstances) { + for(final NetworkInterface iface: Collections.list(NetworkInterface.getNetworkInterfaces())) { + if ( iface.isLoopback() ){ + continue; + } + if ( iface.isPointToPoint()){ + continue; + } + if ( ! iface.isUp()){ + continue; + } + + for(final InetAddress addr: Collections.list(iface.getInetAddresses())) { + if ( ! (addr instanceof Inet4Address) && ! (addr instanceof Inet6Address) ){ + continue; + } + + try { + /* Create mDNS responder for address */ + final JmDNS jmDNS = JmDNS.create(addr, hostName + "-jmdns"); + jmDNSInstances.add(jmDNS); + + /* Publish RAOP service */ + final ServiceInfo airTunesServiceInfo = ServiceInfo.create( + AIR_TUNES_SERVICE_TYPE, + hardwareAddressString + "@" + hostName + " (" + iface.getName() + ")", + getRtspPort(), + 0 /* weight */, 0 /* priority */, + AIRTUNES_SERVICE_PROPERTIES + ); + jmDNS.registerService(airTunesServiceInfo); + LOG.info("Registered AirTunes service '" + airTunesServiceInfo.getName() + "' on " + addr); + } + catch (final Throwable e) { + LOG.log(Level.SEVERE, "Failed to publish service on " + addr, e); + } + } + } + } + + } + catch (SocketException e) { + LOG.log(Level.SEVERE, "Failed register mDNS services", e); + } + } + + //When the app is shutdown + protected void onShutdown() { + /* Close channels */ + final ChannelGroupFuture allChannelsClosed = channelGroup.close(); + + /* Stop all mDNS responders */ + synchronized(jmDNSInstances) { + for(final JmDNS jmDNS: jmDNSInstances) { + try { + jmDNS.unregisterAllServices(); + LOG.info("Unregistered all services on " + jmDNS.getInterface()); + } + catch (final IOException e) { + LOG.log(Level.WARNING, "Failed to unregister some services", e); + + } + } + } + + /* Wait for all channels to finish closing */ + allChannelsClosed.awaitUninterruptibly(); + + /* Stop the ExecutorService */ + executorService.shutdown(); + + /* Release the OrderedMemoryAwareThreadPoolExecutor */ + channelExecutionHandler.releaseExternalResources(); + + } + + /** + * Map factory. Creates a Map from a list of keys and values + * + * @param keys_values key1, value1, key2, value2, ... + * @return a map mapping key1 to value1, key2 to value2, ... + */ + private static Map map(final String... keys_values) { + assert keys_values.length % 2 == 0; + final Map map = new java.util.HashMap(keys_values.length / 2); + for(int i=0; i < keys_values.length; i+=2) + map.put(keys_values[i], keys_values[i+1]); + return Collections.unmodifiableMap(map); + } + + public ChannelHandler getChannelExecutionHandler() { + return channelExecutionHandler; + } + + public ChannelGroup getChannelGroup() { + return channelGroup; + } + + public ExecutorService getExecutorService() { + return executorService; + } + +} diff --git a/src/main/java/nz/co/iswe/android/airplay/audio/AudioOutputQueue.java b/src/main/java/nz/co/iswe/android/airplay/audio/AudioOutputQueue.java new file mode 100644 index 0000000..b01316e --- /dev/null +++ b/src/main/java/nz/co/iswe/android/airplay/audio/AudioOutputQueue.java @@ -0,0 +1,662 @@ +/* + * This file is part of AirReceiver. + * + * AirReceiver is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * AirReceiver is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with AirReceiver. If not, see . + */ + +package nz.co.iswe.android.airplay.audio; + +import java.util.Arrays; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.phlo.AirReceiver.AudioClock; + +import android.media.AudioManager; +import android.media.AudioTrack; + +/** + * Audio output queue. + * + * Serves an an {@link AudioClock} and allows samples to be queued + * for playback at a specific time. + */ +public class AudioOutputQueue implements AudioClock { + private static Logger LOG = Logger.getLogger(AudioOutputQueue.class.getName()); + + private static final double QUEUE_LENGHT_MAX_SECONDS = 10; + private static final double BUFFER_SIZE_SECONDS = 0.05; + private static final double TIMING_PRECISION = 0.001; + + /** + * Signals that the queue is being closed. + * Never transitions from true to false! + */ + private volatile boolean closing = false; + + /** + * The line's audio format + */ + //private final AudioFormat m_format; + + private int streamType; + private int sampleRateInHz; + private int channelConfig; + private int audioFormat; + private int bufferSizeInBytes; + private int mode; + + /** + * True if the line's audio format is signed but + * the requested format was unsigned + */ + private final boolean convertUnsignedToSigned; + + /** + * Bytes per frame, i.e. number of bytes + * per sample times the number of channels + */ + private final int bytesPerFrame; + + /** + * Sample rate + */ + private final double sampleRate; + + /** + * Average packet size in frames. + * + * It is used to detec the gaps between the packets and + * as the number of silence frames + * to write on a queue underrun + */ + private final int packetSizeFrames; + + /** + * JavaSounds audio output line + */ + //private final SourceDataLine m_line; + + /** + * Android audio track (replaces the SourceDataLine) + */ + private AudioTrack audioTrack; + + /** + * The last frame written to the line. + * Used to generate filler data + */ + private final byte[] lineLastFrame; + + /** + * Packet queue, indexed by playback time + */ + private final ConcurrentSkipListMap frameQueue = new ConcurrentSkipListMap(); + + /** + * Enqueuer thread + */ + private final Thread queueThread = new Thread(new EnQueuer()); + + /** + * Number of frames appended to the line + */ + private long framesWrittenToLine = 0; + + /** + * Largest frame time seen so far + */ + private long latestSeenFrameTime = 0; + + /** + * The frame time corresponding to line time zero + */ + private long frameTimeOffset = 0; + + /** + * The seconds time corresponding to line time zero + */ + private double secondsTimeOffset; + + /** + * Requested volume + */ + private float requestedVolume = AudioTrack.getMaxVolume(); + + /** + * Current volume + */ + private float currentVolume = AudioTrack.getMaxVolume(); + + public AudioOutputQueue(final AudioStreamInformationProvider streamInfoProvider) { + //final AudioFormat audioFormat = streamInfoProvider.getAudioFormat(); + + /* OSX does not support unsigned PCM lines. We thus always request + * a signed line, and convert from unsigned to signed if necessary + + if (AudioFormat.Encoding.PCM_SIGNED.equals(audioFormat.getEncoding())) { + m_format = audioFormat; + m_convertUnsignedToSigned = false; + } + else if (AudioFormat.Encoding.PCM_UNSIGNED.equals(audioFormat.getEncoding())) { + //iOS + m_format = new AudioFormat( + audioFormat.getSampleRate(), + audioFormat.getSampleSizeInBits(), + audioFormat.getChannels(), + true, + audioFormat.isBigEndian() + ); + m_convertUnsignedToSigned = true; + } + else { + throw new LineUnavailableException("Audio encoding " + audioFormat.getEncoding() + " is not supported"); + } + */ + convertUnsignedToSigned = true; + + //setup the Audio Format options + streamType = AudioManager.STREAM_MUSIC; + + sampleRateInHz = streamInfoProvider.getSampleRate(); + channelConfig = streamInfoProvider.getChannels(); + audioFormat = streamInfoProvider. getAudioFormat(); + + sampleRate = streamInfoProvider.getSampleRate(); + + /* Audio format-dependent stuff */ + packetSizeFrames = streamInfoProvider.getFramesPerPacket(); + bytesPerFrame = streamInfoProvider.getChannels() * streamInfoProvider.getSampleSizeInBits() / 8; + + //calculate the buffer size in bytes + bufferSizeInBytes = (int)Math.pow(2, Math.ceil(Math.log(BUFFER_SIZE_SECONDS * sampleRate * bytesPerFrame) / Math.log(2.0))); + + mode = AudioTrack.MODE_STREAM; + + //create the AudioTrack + audioTrack = new AudioTrack(streamType, sampleRateInHz, channelConfig, audioFormat, bufferSizeInBytes, mode); + + LOG.info("AudioTrack created succesfully with a buffer of : " + bufferSizeInBytes + " bytes and : " + bufferSizeInBytes / bytesPerFrame + " frames."); + + //create initial array of "filler" bytes .... + lineLastFrame = new byte[bytesPerFrame]; + for(int b=0; b < lineLastFrame.length; ++b){ + lineLastFrame[b] = (b % 2 == 0) ? (byte)-128 : (byte)0; + } + + /* Create enqueuer thread and wait for the line to start. + * The wait guarantees that the AudioClock functions return + * sensible values right after construction + */ + queueThread.setDaemon(true); + queueThread.setName("Audio Enqueuer"); + queueThread.setPriority(Thread.MAX_PRIORITY); + + /* + queueThread.start(); + + //while ( queueThread.isAlive() && ! m_line.isActive() ){ + while ( queueThread.isAlive() && audioTrack.getPlayState() != AudioTrack.PLAYSTATE_PLAYING){ + Thread.yield(); + } + */ + + /* Initialize the seconds time offset now that the line is running. */ + secondsTimeOffset = 2208988800.0 + System.currentTimeMillis() * 1e-3; + } + + public void startAudioProcessing(){ + queueThread.start(); + + //while ( queueThread.isAlive() && ! m_line.isActive() ){ + while ( queueThread.isAlive() && audioTrack.getPlayState() != AudioTrack.PLAYSTATE_PLAYING){ + Thread.yield(); + } + + /* Initialize the seconds time offset now that the line is running. */ + secondsTimeOffset = 2208988800.0 + System.currentTimeMillis() * 1e-3; + } + + /** + * Enqueuer thread + */ + private class EnQueuer implements Runnable { + /** + * Enqueuer thread main method + */ + @Override + public void run() { + try { + /* Mute line initially to prevent clicks */ + setVolume(Float.NEGATIVE_INFINITY); + + /* Start the line */ + //m_line.start(); + //start the audio track + audioTrack.play(); + + LOG.info("Audio Track started !!!"); + + boolean lineMuted = true; + boolean didWarnGap = false; + while ( ! closing) { + if ( ! frameQueue.isEmpty() ) { + /* Queue filled */ + + /* If the gap between the next packet and the end of line is + * negligible (less than one packet), we write it to the line. + * Otherwise, we fill the line buffer with silence and hope for + * further packets to appear in the queue + */ + final long entryFrameTime = frameQueue.firstKey(); + final long entryLineTime = convertFrameToLineTime(entryFrameTime); + final long gapFrames = entryLineTime - getNextLineTime(); + + + //LOG.info("** gapFrames: " + gapFrames + " packetSizeFrames: " + packetSizeFrames); + + if (gapFrames < -packetSizeFrames) { + /* Too late for playback */ + LOG.warning("Audio data was scheduled for playback " + (-gapFrames) + " frames ago, skipping"); + frameQueue.remove(entryFrameTime); + continue; + } + else if (gapFrames < packetSizeFrames) { + /* Negligible gap between packet and line end. Prepare packet for playback */ + didWarnGap = false; + + /* Unmute line in case it was muted previously */ + if (lineMuted) { + LOG.info("Audio data available, un-muting line"); + + lineMuted = false; + applyVolume(); + } + else if (getVolume() != getRequestedVolume()) { + applyVolume(); + } + + /* Get sample data and do sanity checks */ + final byte[] nextPlaybackSamples = frameQueue.remove(entryFrameTime); + int nextPlaybackSamplesLength = nextPlaybackSamples.length; + if (nextPlaybackSamplesLength % bytesPerFrame != 0) { + LOG.severe("Audio data contains non-integral number of frames, ignore last " + (nextPlaybackSamplesLength % bytesPerFrame) + " bytes"); + nextPlaybackSamplesLength -= nextPlaybackSamplesLength % bytesPerFrame; + } + + /* Append packet to line */ + LOG.finest("Audio data containing " + nextPlaybackSamplesLength / bytesPerFrame + " frames for playback time " + entryFrameTime + " found in queue, appending to the output line"); + + appendFrames(nextPlaybackSamples, 0, nextPlaybackSamplesLength, entryLineTime); + + continue; + } + else { + /* Gap between packet and line end. Warn */ + if ( ! didWarnGap) { + didWarnGap = true; + LOG.warning("Audio data missing for frame time " + getNextLineTime() + " (currently " + gapFrames + " frames), writing " + packetSizeFrames + " frames of silence"); + } + } + } + else { + /* Queue empty */ + + if ( ! lineMuted) { + lineMuted = true; + setVolume(Float.NEGATIVE_INFINITY); + LOG.fine("Audio data ended at frame time " + getNextLineTime() + ", writing " + packetSizeFrames + " frames of silence and muted line"); + } + } + + appendSilence(packetSizeFrames); + } + + //TODO: I don't think we need the appendSilence anymore when using Android API, but will evaluate that later during tests + /* Before we exit, we fill the line's buffer with silence. This should prevent + * noise from being output while the line is being stopped + */ + //appendSilence(m_line.available() / m_bytesPerFrame); + } + catch (final Throwable e) { + LOG.log(Level.SEVERE, "Audio output thread died unexpectedly", e); + } + finally { + setVolume(Float.NEGATIVE_INFINITY); + audioTrack.stop(); + audioTrack.release(); + //m_line.stop(); + //m_line.close(); + } + } + + /** + * Append the range [off,off+len) from the provided sample data to the line. + * If the requested playback time does not match the line end time, samples are + * skipped or silence is inserted as necessary. If the data is marked as being + * just a filler, some warnings are suppressed. + * + * @param samples sample data + * @param off sample data offset + * @param len sample data length + * @param time playback time + * @param warnNonContinous warn about non-continous samples + */ + private void appendFrames(final byte[] samples, int off, final int len, long lineTime) { + assert off % bytesPerFrame == 0; + assert len % bytesPerFrame == 0; + + while (true) { + /* Fetch line end time only once per iteration */ + final long endLineTime = getNextLineTime(); + + final long timingErrorFrames = lineTime - endLineTime; + final double timingErrorSeconds = timingErrorFrames / sampleRate; + + if (Math.abs(timingErrorSeconds) <= TIMING_PRECISION) { + /* Samples to append scheduled exactly at line end. Just append them and be done */ + appendFrames(samples, off, len); + break; + } + else if (timingErrorFrames > 0) { + /* Samples to append scheduled after the line end. Fill the gap with silence */ + LOG.warning("Audio output non-continous (gap of " + timingErrorFrames + " frames), filling with silence"); + + appendSilence((int)(lineTime - endLineTime)); + } + else if (timingErrorFrames < 0) { + /* Samples to append scheduled before the line end. Remove the overlapping + * part and retry + */ + LOG.warning("Audio output non-continous (overlap of " + (-timingErrorFrames) + "), skipping overlapping frames"); + + off += (endLineTime - lineTime) * bytesPerFrame; + lineTime += endLineTime - lineTime; + } + else { + /* Strange universe... */ + assert false; + } + } + } + + private void appendSilence(final int frames) { + LOG.info("Appending Silence to the AudioTrack. frames: " + frames); + + final byte[] silenceFrames = new byte[frames * bytesPerFrame]; + for(int i = 0; i < silenceFrames.length; ++i){ + silenceFrames[i] = lineLastFrame[i % bytesPerFrame]; + } + appendFrames(silenceFrames, 0, silenceFrames.length); + } + + /** + * Append the range [off, off+len) from the provided sample data to the line. + * + * @param samples sample data + * @param off sample data offset + * @param len sample data length + */ + private void appendFrames(final byte[] samples, int off, int len) { + assert off % bytesPerFrame == 0; + assert len % bytesPerFrame == 0; + + /* Make sure that [off, off+len) does not exceed sample's bounds */ + off = Math.min(off, (samples != null) ? samples.length : 0); + + len = Math.min(len, (samples != null) ? samples.length - off : 0); + + if (len <= 0){ + return; + } + + /* Convert samples if necessary */ + final byte[] samplesConverted = Arrays.copyOfRange(samples, off, off + len); + + if (convertUnsignedToSigned) { + /* The line expects signed PCM samples, so we must + * convert the unsigned PCM samples to signed. + * Note that this only affects the high bytes! + */ + for(int i=0; i < samplesConverted.length; i += 2){ + samplesConverted[i] = (byte)((samplesConverted[i] & 0xff) - 0x80); + } + } + + /* Write samples to line */ + //final int bytesWritten = m_line.write(samplesConverted, 0, samplesConverted.length); + final int bytesWritten = audioTrack.write(samplesConverted, 0, samplesConverted.length); + + if(bytesWritten == AudioTrack.ERROR_INVALID_OPERATION){ + LOG.severe("Audio Track not initialized properly"); + throw new RuntimeException("Audio Track not initialized properly: AudioTrack status: ERROR_INVALID_OPERATION"); + } + else if(bytesWritten == AudioTrack.ERROR_BAD_VALUE){ + LOG.severe("Wrong parameters sent to Audio Track!"); + throw new RuntimeException("Wrong parameters sent to Audio Track! AudioTrack status: ERROR_BAD_VALUE"); + } + else if (bytesWritten != len){ + LOG.warning("Audio output line accepted only " + bytesWritten + " bytes of sample data while trying to write " + samples.length + " bytes"); + } + else{ + LOG.info(bytesWritten + " bytes written to the audio output line"); + } + + /* Update state */ + synchronized(AudioOutputQueue.this) { + framesWrittenToLine += (bytesWritten / bytesPerFrame); + + for(int b=0; b < bytesPerFrame; ++b){ + lineLastFrame[b] = samples[off + len - (bytesPerFrame - b)]; + } + + if(LOG.isLoggable(Level.FINE)){ + LOG.finest("Audio output line end is now at " + getNextLineTime() + " after writing " + len / bytesPerFrame + " frames"); + } + } + } + } + + /** + * Sets the AudioTrack the Stereo Volume + * + * @param volume + * + */ + private void setVolume(float volume) { + currentVolume = volume; + setStereoVolume(volume, volume); + } + + /** + * Sets the AudioTrack the Stereo Volume + * + * @param leftVolume + * @param rightVolume + * + */ + private void setStereoVolume(float leftVolume, float rightVolume) { + + + leftVolume = AudioTrack.getMaxVolume(); + rightVolume = AudioTrack.getMaxVolume(); + + //validate left volume + if(leftVolume < AudioTrack.getMinVolume()){ + leftVolume = AudioTrack.getMinVolume(); + } + if(leftVolume > AudioTrack.getMaxVolume()){ + leftVolume = AudioTrack.getMaxVolume(); + } + + //validate right volume + if(rightVolume < AudioTrack.getMinVolume()){ + rightVolume = AudioTrack.getMinVolume(); + } + if(rightVolume > AudioTrack.getMaxVolume()){ + rightVolume = AudioTrack.getMaxVolume(); + } + + LOG.info("setStereoVolume() leftVolume: " + leftVolume + " rightVolume: " + rightVolume); + + audioTrack.setStereoVolume(leftVolume, rightVolume); + } + + /** + * Returns the line's MASTER_GAIN control's value. + */ + private float getVolume() { + return currentVolume; + } + + private synchronized void applyVolume() { + setVolume(requestedVolume); + } + + /** + * Sets the desired output gain. + * + * @param volume desired gain + */ + public synchronized void setRequestedVolume(final float volume) { + requestedVolume = volume; + } + + /** + * Returns the desired output gain. + * + * @param gain desired gain + */ + public synchronized float getRequestedVolume() { + return requestedVolume; + } + + /** + * Stops audio output + */ + public void close() { + closing = true; + queueThread.interrupt(); + } + + /** + * Adds sample data to the queue + * + * @param playbackRemoteStartFrameTime start time of sample data + * @param playbackSamples sample data + * @return true if the sample data was added to the queue + */ + public synchronized boolean enqueue(final long frameTime, final byte[] frames) { + /* Playback time of packet */ + final double packetSeconds = (double)frames.length / (double)(bytesPerFrame * sampleRate); + + /* Compute playback delay, i.e., the difference between the last sample's + * playback time and the current line time + */ + long nextLineTime = getNextLineTime(); + long frameToLineTime = convertFrameToLineTime(frameTime); + final double delay = (frameToLineTime + frames.length / bytesPerFrame - nextLineTime) / sampleRate; + + latestSeenFrameTime = Math.max(latestSeenFrameTime, frameTime); + + LOG.info(" delay: " + delay ); + + if (delay < -packetSeconds) { + /* The whole packet is scheduled to be played in the past */ + LOG.warning("Audio data arrived " + -(delay) + " seconds too late, dropping"); + return false; + } + else if (delay > QUEUE_LENGHT_MAX_SECONDS) { + /* The packet extends further into the future that our maximum queue size. + * We reject it, since this is probably the result of some timing discrepancies + */ + LOG.warning("Audio data arrived " + delay + " seconds too early, dropping"); + return false; + } + + LOG.info("frames added to the frameQueue. frameTime: " + frameTime + " frames.length: " + frames.length + " frames: " + frames); + + frameQueue.put(frameTime, frames); + return true; + } + + /** + * Removes all currently queued sample data + */ + public void flush() { + frameQueue.clear(); + } + + @Override + public synchronized void setFrameTime(final long frameTime, final double secondsTime) { + final double ageSeconds = getNowSecondsTime() - secondsTime; + final long lineTime = Math.round((secondsTime - secondsTimeOffset) * sampleRate); + + final long frameTimeOffsetPrevious = frameTimeOffset; + frameTimeOffset = frameTime - lineTime; + + LOG.info("Frame time adjusted by " + (frameTimeOffset - frameTimeOffsetPrevious) + " based on timing information " + ageSeconds + " seconds old and " + (latestSeenFrameTime - frameTime) + " frames before latest seen frame time. previous: " + frameTimeOffsetPrevious + " new frameTimeOffset: " + frameTimeOffset); + } + + @Override + public double getNowSecondsTime() { + double value = secondsTimeOffset + getNowLineTime() / sampleRate; + //LOG.info("getNowSecondsTime(): " + value); + return value; + } + + //@Override + //public long getNowFrameTime() { + // return frameTimeOffset + getNowLineTime(); + //} + + @Override + public double getNextSecondsTime() { + return secondsTimeOffset + getNextLineTime() / sampleRate; + } + + @Override + public long getNextFrameTime() { + return frameTimeOffset + getNextLineTime(); + } + + @Override + public double convertFrameToSecondsTime(final long frameTime) { + return secondsTimeOffset + (frameTime - frameTimeOffset) / sampleRate; + } + + private synchronized long getNextLineTime() { + return framesWrittenToLine; + } + + private long getNowLineTime() { + //getPlaybackHeadPosition() + //getNotificationMarkerPosition + //return audioTrack.getNotificationMarkerPosition(); + //return m_line.getLongFramePosition(); + if(audioTrack.getPlayState() == AudioTrack.PLAYSTATE_PLAYING){ + long value = audioTrack.getPlaybackHeadPosition(); + return value; + } + else{ + LOG.warning("getNowLineTime() called while audioTrack is not on a Playing State"); + return 0; + } + } + + private synchronized long convertFrameToLineTime(final long entryFrameTime) { + return entryFrameTime - frameTimeOffset; + } +} diff --git a/src/main/java/org/phlo/AirReceiver/AudioStreamInformationProvider.java b/src/main/java/nz/co/iswe/android/airplay/audio/AudioStreamInformationProvider.java similarity index 69% rename from src/main/java/org/phlo/AirReceiver/AudioStreamInformationProvider.java rename to src/main/java/nz/co/iswe/android/airplay/audio/AudioStreamInformationProvider.java index 78a5c4b..744fa8b 100644 --- a/src/main/java/org/phlo/AirReceiver/AudioStreamInformationProvider.java +++ b/src/main/java/nz/co/iswe/android/airplay/audio/AudioStreamInformationProvider.java @@ -15,9 +15,8 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.audio; -import javax.sound.sampled.AudioFormat; /** * Provides information about an audio stream @@ -27,17 +26,28 @@ public interface AudioStreamInformationProvider { * The JavaSoune audio format of the streamed audio * @return the AudioFormat */ - public AudioFormat getAudioFormat(); + //public AudioFormat getAudioFormat(); /** * Average frames per second * @return frames per second */ - public int getFramesPerPacket(); + int getFramesPerPacket(); /** * Average packets per second * @return packets per second */ - public double getPacketsPerSecond(); + double getPacketsPerSecond(); + + //Audio Format Sample Rate + int getSampleRate(); + + int getSampleSizeInBits(); + + //Number of audio channels : 1 mono / 2 stereo + int getChannels(); + + //The format in which the audio data is represented. See Android AudioFormat.ENCODING_PCM_16BIT and Android AudioFormat.ENCODING_PCM_8BIT + int getAudioFormat(); } diff --git a/src/main/java/org/phlo/AirReceiver/RaopAudioHandler.java b/src/main/java/nz/co/iswe/android/airplay/audio/RaopAudioHandler.java similarity index 59% rename from src/main/java/org/phlo/AirReceiver/RaopAudioHandler.java rename to src/main/java/nz/co/iswe/android/airplay/audio/RaopAudioHandler.java index 754455a..5053854 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopAudioHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/audio/RaopAudioHandler.java @@ -15,40 +15,79 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; - -import java.net.*; -import java.nio.charset.*; -import java.util.*; +package nz.co.iswe.android.airplay.audio; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.charset.Charset; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.regex.*; - -import javax.crypto.*; -import javax.crypto.spec.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import nz.co.iswe.android.airplay.AirPlayServer; +import nz.co.iswe.android.airplay.crypto.AirTunesCryptography; +import nz.co.iswe.android.airplay.network.ExceptionLoggingHandler; +import nz.co.iswe.android.airplay.network.RtpEncodeHandler; +import nz.co.iswe.android.airplay.network.RtpLoggingHandler; +import nz.co.iswe.android.airplay.network.raop.RaopRtpDecodeHandler; +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket; +import nz.co.iswe.android.airplay.network.raop.RaopRtpTimingHandler; import org.jboss.netty.bootstrap.ConnectionlessBootstrap; import org.jboss.netty.buffer.ChannelBuffers; -import org.jboss.netty.channel.*; -import org.jboss.netty.channel.group.*; -import org.jboss.netty.channel.socket.nio.NioDatagramChannelFactory; -import org.jboss.netty.handler.codec.http.*; -import org.jboss.netty.handler.codec.rtsp.*; +import org.jboss.netty.channel.Channel; +import org.jboss.netty.channel.ChannelFuture; +import org.jboss.netty.channel.ChannelFutureListener; +import org.jboss.netty.channel.ChannelHandler; +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.channel.ChannelPipeline; +import org.jboss.netty.channel.ChannelPipelineFactory; +import org.jboss.netty.channel.ChannelStateEvent; +import org.jboss.netty.channel.Channels; +import org.jboss.netty.channel.FixedReceiveBufferSizePredictorFactory; +import org.jboss.netty.channel.MessageEvent; +import org.jboss.netty.channel.SimpleChannelDownstreamHandler; +import org.jboss.netty.channel.SimpleChannelUpstreamHandler; +import org.jboss.netty.channel.UpstreamMessageEvent; +import org.jboss.netty.channel.group.ChannelGroup; +import org.jboss.netty.channel.group.DefaultChannelGroup; +import org.jboss.netty.channel.socket.oio.OioDatagramChannelFactory; +import org.jboss.netty.handler.codec.http.DefaultHttpResponse; +import org.jboss.netty.handler.codec.http.HttpMethod; +import org.jboss.netty.handler.codec.http.HttpRequest; +import org.jboss.netty.handler.codec.http.HttpResponse; +import org.jboss.netty.handler.codec.rtsp.RtspResponseStatuses; +import org.jboss.netty.handler.codec.rtsp.RtspVersions; +import org.phlo.AirReceiver.Base64; +import org.phlo.AirReceiver.ProtocolException; +import org.phlo.AirReceiver.RaopRtpRetransmitRequestHandler; +import org.phlo.AirReceiver.RaopRtspMethods; /** * Handles the configuration, creation and destruction of RTP channels. */ public class RaopAudioHandler extends SimpleChannelUpstreamHandler { - private static Logger s_logger = Logger.getLogger(RaopAudioHandler.class.getName()); + private static Logger LOG = Logger.getLogger(RaopAudioHandler.class.getName()); /** * The RTP channel type */ static enum RaopRtpChannelType { Audio, Control, Timing }; - private static final String HeaderTransport = "Transport"; - private static final String HeaderSession = "Session"; + private static final String HEADER_TRANSPORT = "Transport"; + private static final String HEADER_SESSION = "Session"; /** * Routes incoming packets from the control and timing channel to @@ -56,18 +95,17 @@ static enum RaopRtpChannelType { Audio, Control, Timing }; */ private class RaopRtpInputToAudioRouterUpstreamHandler extends SimpleChannelUpstreamHandler { @Override - public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) - throws Exception - { + public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { + /* Get audio channel from the enclosing RaopAudioHandler */ - Channel audioChannel = null; + Channel tempAudioChannel = null; synchronized(RaopAudioHandler.this) { - audioChannel = m_audioChannel; + tempAudioChannel = audioChannel; } - if ((m_audioChannel != null) && m_audioChannel.isOpen() && m_audioChannel.isReadable()) { - audioChannel.getPipeline().sendUpstream(new UpstreamMessageEvent( - audioChannel, + if ((tempAudioChannel != null) && tempAudioChannel.isOpen() && tempAudioChannel.isReadable()) { + tempAudioChannel.getPipeline().sendUpstream(new UpstreamMessageEvent( + tempAudioChannel, evt.getMessage(), evt.getRemoteAddress()) ); @@ -81,26 +119,27 @@ public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent */ private class RaopRtpAudioToOutputRouterDownstreamHandler extends SimpleChannelDownstreamHandler { @Override - public void writeRequested(final ChannelHandlerContext ctx, final MessageEvent evt) - throws Exception - { + public void writeRequested(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { final RaopRtpPacket packet = (RaopRtpPacket)evt.getMessage(); /* Get control and timing channel from the enclosing RaopAudioHandler */ - Channel controlChannel = null; - Channel timingChannel = null; + Channel tempControlChannel = null; + Channel tempTimingChannel = null; + synchronized(RaopAudioHandler.this) { - controlChannel = m_controlChannel; - timingChannel = m_timingChannel; + tempControlChannel = controlChannel; + tempTimingChannel = timingChannel; } if (packet instanceof RaopRtpPacket.RetransmitRequest) { - if ((controlChannel != null) && controlChannel.isOpen() && controlChannel.isWritable()) - controlChannel.write(evt.getMessage()); + if ((tempControlChannel != null) && tempControlChannel.isOpen() && tempControlChannel.isWritable()){ + tempControlChannel.write(evt.getMessage()); + } } else if (packet instanceof RaopRtpPacket.TimingRequest) { - if ((timingChannel != null) && timingChannel.isOpen() && timingChannel.isWritable()) - timingChannel.write(evt.getMessage()); + if ((tempTimingChannel != null) && tempTimingChannel.isOpen() && tempTimingChannel.isWritable()){ + tempTimingChannel.write(evt.getMessage()); + } } else { super.writeRequested(ctx, evt); @@ -114,10 +153,9 @@ else if (packet instanceof RaopRtpPacket.TimingRequest) { */ public class RaopRtpAudioEnqueueHandler extends SimpleChannelUpstreamHandler { @Override - public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) - throws Exception - { - if (!(evt.getMessage() instanceof RaopRtpPacket.Audio)) { + public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { + if ( ! (evt.getMessage() instanceof RaopRtpPacket.Audio) ) { + //in case it is NOT a Audio packet super.messageReceived(ctx, evt); return; } @@ -125,20 +163,28 @@ public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent final RaopRtpPacket.Audio audioPacket = (RaopRtpPacket.Audio)evt.getMessage(); /* Get audio output queue from the enclosing RaopAudioHandler */ - AudioOutputQueue audioOutputQueue; + AudioOutputQueue tempAudioOutputQueue; synchronized(RaopAudioHandler.this) { - audioOutputQueue = m_audioOutputQueue; + tempAudioOutputQueue = audioOutputQueue; } - if (audioOutputQueue != null) { + if (tempAudioOutputQueue != null) { + //buffer array the byte with the audio samples final byte[] samples = new byte[audioPacket.getPayload().capacity()]; + + //get the bytes audioPacket.getPayload().getBytes(0, samples); - m_audioOutputQueue.enqueue(audioPacket.getTimeStamp(), samples); - if (s_logger.isLoggable(Level.FINEST)) - s_logger.finest("Packet with sequence " + audioPacket.getSequence() + " for playback at " + audioPacket.getTimeStamp() + " submitted to audio output queue"); + + //send the audio buffer to the autioOutputQueue + //audioOutputQueue.enqueue(audioPacket.getTimeStamp(), samples); + tempAudioOutputQueue.enqueue(audioPacket.getTimeStamp(), samples); + + if (LOG.isLoggable(Level.FINEST)){ + LOG.finest("Packet with sequence " + audioPacket.getSequence() + " for playback at " + audioPacket.getTimeStamp() + " submitted to audio output queue"); + } } else { - s_logger.warning("No audio queue available, dropping packet"); + LOG.warning("No audio queue available, dropping packet"); } super.messageReceived(ctx, evt); @@ -148,43 +194,61 @@ public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent /** * RSA cipher used to decrypt the AES session key */ - private final Cipher m_rsaPkCS1OaepCipher = AirTunesCrytography.getCipher("RSA/None/OAEPWithSHA1AndMGF1Padding"); + //private final Cipher m_rsaPkCS1OaepCipher = AirTunesCrytography.getCipher("RSA/None/OAEPWithSHA1AndMGF1Padding"); + private Cipher rsaPkCS1OaepCipher; + /** * Executor service used for the RTP channels */ - private final ExecutorService m_rtpExecutorService; - - private final ChannelHandler m_exceptionLoggingHandler = new ExceptionLoggingHandler(); - private final ChannelHandler m_decodeHandler = new RaopRtpDecodeHandler(); - private final ChannelHandler m_encodeHandler = new RtpEncodeHandler(); - private final ChannelHandler m_packetLoggingHandler = new RtpLoggingHandler(); - private final ChannelHandler m_inputToAudioRouterDownstreamHandler = new RaopRtpInputToAudioRouterUpstreamHandler(); - private final ChannelHandler m_audioToOutputRouterUpstreamHandler = new RaopRtpAudioToOutputRouterDownstreamHandler(); - private ChannelHandler m_decryptionHandler; - private ChannelHandler m_audioDecodeHandler; - private ChannelHandler m_resendRequestHandler; - private ChannelHandler m_timingHandler; - private final ChannelHandler m_audioEnqueueHandler = new RaopRtpAudioEnqueueHandler(); - - private AudioStreamInformationProvider m_audioStreamInformationProvider; - private AudioOutputQueue m_audioOutputQueue; + private final ExecutorService rtpExecutorService; + + private final ChannelHandler exceptionLoggingHandler = new ExceptionLoggingHandler(); + private final ChannelHandler decodeHandler = new RaopRtpDecodeHandler(); + private final ChannelHandler encodeHandler = new RtpEncodeHandler(); + private final ChannelHandler packetLoggingHandler = new RtpLoggingHandler(); + private final ChannelHandler inputToAudioRouterDownstreamHandler = new RaopRtpInputToAudioRouterUpstreamHandler(); + private final ChannelHandler audioToOutputRouterUpstreamHandler = new RaopRtpAudioToOutputRouterDownstreamHandler(); + + private ChannelHandler decryptionHandler; + private ChannelHandler audioDecodeHandler; + private ChannelHandler resendRequestHandler; + private RaopRtpTimingHandler timingHandler; + private final ChannelHandler audioEnqueueHandler = new RaopRtpAudioEnqueueHandler(); + + private AudioStreamInformationProvider audioStreamInformationProvider; + private AudioOutputQueue audioOutputQueue; /** * All RTP channels belonging to this RTSP connection */ - private final ChannelGroup m_rtpChannels = new DefaultChannelGroup(); + private final ChannelGroup rtpChannels = new DefaultChannelGroup(); - private Channel m_audioChannel; - private Channel m_controlChannel; - private Channel m_timingChannel; + private Channel audioChannel; + private Channel controlChannel; + private Channel timingChannel; /** * Creates an instance, using the ExecutorService for the RTP channel's datagram socket factory * @param rtpExecutorService */ public RaopAudioHandler(final ExecutorService rtpExecutorService) { - m_rtpExecutorService = rtpExecutorService; + this.rtpExecutorService = rtpExecutorService; + + //TODO: MOve this to the AirTunesCryptography class + String transformation = "RSA/None/OAEPWithSHA1AndMGF1Padding"; + try { + rsaPkCS1OaepCipher = Cipher.getInstance(transformation); + + LOG.info("Cipher acquired sucessfully. transformation: " + transformation); + } + catch (NoSuchAlgorithmException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } + catch (NoSuchPaddingException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } + reset(); } @@ -192,29 +256,29 @@ public RaopAudioHandler(final ExecutorService rtpExecutorService) { * Resets stream-related data (i.e. undoes the effect of ANNOUNCE, SETUP and RECORD */ private void reset() { - if (m_audioOutputQueue != null) - m_audioOutputQueue.close(); + if (audioOutputQueue != null){ + audioOutputQueue.close(); + } - m_rtpChannels.close(); + rtpChannels.close(); - m_decryptionHandler = null; - m_audioDecodeHandler = null; - m_resendRequestHandler = null; - m_timingHandler = null; + decryptionHandler = null; + audioDecodeHandler = null; + resendRequestHandler = null; + timingHandler = null; - m_audioStreamInformationProvider = null; - m_audioOutputQueue = null; + audioStreamInformationProvider = null; + audioOutputQueue = null; - m_audioChannel = null; - m_controlChannel = null; - m_timingChannel = null; + audioChannel = null; + controlChannel = null; + timingChannel = null; } @Override public void channelClosed(final ChannelHandlerContext ctx, final ChannelStateEvent evt) - throws Exception - { - s_logger.info("RTSP connection was shut down, closing RTP channels and audio output queue"); + throws Exception { + LOG.info("RTSP connection was shut down, closing RTP channels and audio output queue"); synchronized(this) { reset(); @@ -228,6 +292,8 @@ public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent 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; @@ -267,7 +333,7 @@ else if (RaopRtspMethods.GET_PARAMETER.equals(method)) { * = * } */ - private static Pattern s_pattern_sdp_line = Pattern.compile("^([a-z])=(.*)$"); + protected static Pattern s_pattern_sdp_line = Pattern.compile("^([a-z])=(.*)$"); /** * SDP attribute {@code m}. Format is @@ -279,7 +345,7 @@ else if (RaopRtspMethods.GET_PARAMETER.equals(method)) { * RAOP/AirTunes always required {@code =audio, =RTP/AVP} * and only a single format is allowed. The port is ignored. */ - private static Pattern s_pattern_sdp_m = Pattern.compile("^audio ([^ ]+) RTP/AVP ([0-9]+)$"); + protected static Pattern s_pattern_sdp_m = Pattern.compile("^audio ([^ ]+) RTP/AVP ([0-9]+)$"); /** * SDP attribute {@code a}. Format is @@ -295,10 +361,12 @@ else if (RaopRtspMethods.GET_PARAMETER.equals(method)) { *
  • {@code =rtpmap} *
  • {@code =fmtp} *
  • {@code =rsaaeskey} - *
  • {@code =aesiv} + *
  • {@code =aesiv} + * + *
  • {@code =min-latency} * */ - private static Pattern s_pattern_sdp_a = Pattern.compile("^([a-z]+):(.*)$"); + protected static Pattern s_pattern_sdp_a = Pattern.compile("^(\\w+):?(.*)$"); //changed to support dash in the attribute name /** * SDP {@code a} attribute {@code rtpmap}. Format is @@ -321,19 +389,37 @@ else if (RaopRtspMethods.GET_PARAMETER.equals(method)) { * */ public synchronized void announceReceived(final ChannelHandlerContext ctx, final HttpRequest req) - throws Exception - { + throws Exception { + /* ANNOUNCE must contain stream information in SDP format */ - if (!req.containsHeader("Content-Type")) + if ( ! req.containsHeader("Content-Type")){ throw new ProtocolException("No Content-Type header"); - if (!"application/sdp".equals(req.getHeader("Content-Type"))) + } + if ( ! "application/sdp".equals(req.getHeader("Content-Type")) ){ throw new ProtocolException("Invalid Content-Type header, expected application/sdp but got " + req.getHeader("Content-Type")); - + } + reset(); /* Get SDP stream information */ - final String dsp = req.getContent().toString(Charset.forName("ASCII")).replace("\r", ""); + final String sdp = req.getContent().toString(Charset.forName("ASCII")).replace("\r", ""); + /** + * 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 + */ + + //TODO: move this parsing into a SDP class. SecretKey aesKey = null; IvParameterSpec aesIv = null; int alacFormatIndex = -1; @@ -341,13 +427,17 @@ public synchronized void announceReceived(final ChannelHandlerContext ctx, final int descriptionFormatIndex = -1; String[] formatOptions = null; - for(final String line: dsp.split("\n")) { + //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 line_matcher = s_pattern_sdp_line.matcher(line); - if (!line_matcher.matches()) + final Matcher lineMatcher = s_pattern_sdp_line.matcher(line); + + if ( ! lineMatcher.matches()){ throw new ProtocolException("Cannot parse SDP line " + line); - final char attribute = line_matcher.group(1).charAt(0); - final String setting = line_matcher.group(2); + } + + final char attribute = lineMatcher.group(1).charAt(0); + final String setting = lineMatcher.group(2); /* Handle attributes */ switch (attribute) { @@ -360,10 +450,15 @@ public synchronized void announceReceived(final ChannelHandlerContext ctx, final 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()) + + 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); @@ -392,8 +487,8 @@ else if ("rsaaeskey".equals(key)) { */ byte[] aesKeyRaw; - m_rsaPkCS1OaepCipher.init(Cipher.DECRYPT_MODE, AirTunesCrytography.PrivateKey); - aesKeyRaw = m_rsaPkCS1OaepCipher.doFinal(Base64.decodeUnpadded(value)); + rsaPkCS1OaepCipher.init(Cipher.DECRYPT_MODE, AirTunesCryptography.PrivateKey); + aesKeyRaw = rsaPkCS1OaepCipher.doFinal(Base64.decodeUnpadded(value)); aesKey = new SecretKeySpec(aesKeyRaw, "AES"); } @@ -412,35 +507,40 @@ else if ("aesiv".equals(key)) { /* Validate SDP information */ /* The format index of the stream must match the format index from the rtpmap attribute */ - if (alacFormatIndex != audioFormatIndex) + if (alacFormatIndex != audioFormatIndex){ throw new ProtocolException("Audio format " + audioFormatIndex + " not supported"); + } /* The format index from the rtpmap attribute must match the format index from the fmtp attribute */ - if (audioFormatIndex != descriptionFormatIndex) + if (audioFormatIndex != descriptionFormatIndex){ throw new ProtocolException("Auido format " + audioFormatIndex + " lacks fmtp line"); + } /* The fmtp attribute must have contained format options */ - if (formatOptions == null) + if (formatOptions == null){ throw new ProtocolException("Auido format " + audioFormatIndex + " incomplete, format options not set"); + } /* Create decryption handler if an AES key and IV was specified */ - if ((aesKey != null) && (aesIv != null)) - m_decryptionHandler = new RaopRtpAudioDecryptionHandler(aesKey, aesIv); + if ((aesKey != null) && (aesIv != null)){ + decryptionHandler = new RaopRtpAudioDecryptionHandler(aesKey, aesIv); + } /* Create an ALAC decoder. The ALAC decoder is our stream information provider */ final RaopRtpAudioAlacDecodeHandler handler = new RaopRtpAudioAlacDecodeHandler(formatOptions); - m_audioStreamInformationProvider = handler; - m_audioDecodeHandler = handler; + audioStreamInformationProvider = handler; + audioDecodeHandler = handler; /* Create audio output queue with the format information provided by the ALAC decoder */ - m_audioOutputQueue = new AudioOutputQueue(m_audioStreamInformationProvider); + audioOutputQueue = new AudioOutputQueue(audioStreamInformationProvider); /* Create timing handle, using the AudioOutputQueue as time source */ - m_timingHandler = new RaopRtpTimingHandler(m_audioOutputQueue); + timingHandler = new RaopRtpTimingHandler(audioOutputQueue); /* Create retransmit request handler using the audio output queue as time source */ - m_resendRequestHandler = new RaopRtpRetransmitRequestHandler(m_audioStreamInformationProvider, m_audioOutputQueue); + resendRequestHandler = new RaopRtpRetransmitRequestHandler(audioStreamInformationProvider, audioOutputQueue); + //send response back to the client final HttpResponse response = new DefaultHttpResponse(RtspVersions.RTSP_1_0, RtspResponseStatuses.OK); ctx.getChannel().write(response); } @@ -456,70 +556,80 @@ else if ("aesiv".equals(key)) { *

    * For RAOP/AirTunes, {@code } is always {@code RTP/AVP/UDP}. */ - private static Pattern s_pattern_transportOption = Pattern.compile("^([A-Za-z0-9_-]+)(=(.*))?$"); + private static Pattern PATTERN_TRANSPORT_OPTION = Pattern.compile("^([A-Za-z0-9_-]+)(=(.*))?$"); /** * Handles SETUP requests and creates the audio, control and timing RTP channels */ - public synchronized void setupReceived(final ChannelHandlerContext ctx, final HttpRequest req) - throws ProtocolException - { + public synchronized void setupReceived(final ChannelHandlerContext ctx, final HttpRequest req) throws ProtocolException { /* Request must contain a Transport header */ - if (!req.containsHeader(HeaderTransport)) + if ( ! req.containsHeader(HEADER_TRANSPORT)){ throw new ProtocolException("No Transport header"); + } - /* Split Transport header into individual options and prepare reponse options list */ - final Deque requestOptions = new java.util.LinkedList(Arrays.asList(req.getHeader(HeaderTransport).split(";"))); + /* Split Transport header into individual options and prepare response options list */ + final Deque requestOptions = new java.util.LinkedList(Arrays.asList(req.getHeader(HEADER_TRANSPORT).split(";"))); final List responseOptions = new java.util.LinkedList(); /* Transport header. Protocol must be RTP/AVP/UDP */ final String requestProtocol = requestOptions.removeFirst(); - if (!"RTP/AVP/UDP".equals(requestProtocol)) + if ( ! "RTP/AVP/UDP".equals(requestProtocol)){ throw new ProtocolException("Transport protocol must be RTP/AVP/UDP, but was " + requestProtocol); + } + responseOptions.add(requestProtocol); /* Parse incoming transport options and build response options */ for(final String requestOption: requestOptions) { /* Split option into key and value */ - final Matcher m_transportOption = s_pattern_transportOption.matcher(requestOption); - if (!m_transportOption.matches()) + final Matcher transportOption = PATTERN_TRANSPORT_OPTION.matcher(requestOption); + if ( ! transportOption.matches() ){ throw new ProtocolException("Cannot parse Transport option " + requestOption); - final String key = m_transportOption.group(1); - final String value = m_transportOption.group(3); + } + 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)) + 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)) + 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); - m_controlChannel = createRtpChannel( - substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 0), + + controlChannel = createRtpChannel( + substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 53670), substitutePort((InetSocketAddress)ctx.getChannel().getRemoteAddress(), clientControlPort), RaopRtpChannelType.Control ); - s_logger.info("Launched RTP control service on " + m_controlChannel.getLocalAddress()); - responseOptions.add("control_port=" + ((InetSocketAddress)m_controlChannel.getLocalAddress()).getPort()); + + 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); - m_timingChannel = createRtpChannel( - substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 0), + + timingChannel = createRtpChannel( + substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 53669), substitutePort((InetSocketAddress)ctx.getChannel().getRemoteAddress(), clientTimingPort), RaopRtpChannelType.Timing ); - s_logger.info("Launched RTP timing service on " + m_timingChannel.getLocalAddress()); - responseOptions.add("timing_port=" + ((InetSocketAddress)m_timingChannel.getLocalAddress()).getPort()); + + LOG.info("Launched RTP timing service on " + timingChannel.getLocalAddress()); + + responseOptions.add("timing_port=" + ((InetSocketAddress)timingChannel.getLocalAddress()).getPort()); } else { /* Ignore unknown options */ @@ -528,26 +638,29 @@ else if ("timing_port".equals(key)) { } /* Create audio socket and include it's port in our response */ - m_audioChannel = createRtpChannel( - substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 0), + audioChannel = createRtpChannel( + substitutePort((InetSocketAddress)ctx.getChannel().getLocalAddress(), 53671), null, RaopRtpChannelType.Audio ); - s_logger.info("Launched RTP audio service on " + m_audioChannel.getLocalAddress()); - responseOptions.add("server_port=" + ((InetSocketAddress)m_audioChannel.getLocalAddress()).getPort()); + + LOG.info("Launched RTP audio service on " + audioChannel.getLocalAddress()); + + responseOptions.add("server_port=" + ((InetSocketAddress)audioChannel.getLocalAddress()).getPort()); /* Build response options string */ final StringBuilder transportResponseBuilder = new StringBuilder(); for(final String responseOption: responseOptions) { - if (transportResponseBuilder.length() > 0) + if (transportResponseBuilder.length() > 0){ transportResponseBuilder.append(";"); + } transportResponseBuilder.append(responseOption); } /* Send response */ final HttpResponse response = new DefaultHttpResponse(RtspVersions.RTSP_1_0, RtspResponseStatuses.OK); - response.addHeader(HeaderTransport, transportResponseBuilder.toString()); - response.addHeader(HeaderSession, "DEADBEEEF"); + response.addHeader(HEADER_TRANSPORT, transportResponseBuilder.toString()); + response.addHeader(HEADER_SESSION, "DEADBEEEF"); ctx.getChannel().write(response); } @@ -558,14 +671,15 @@ else if ("timing_port".equals(key)) { * iTunes reports the initial RTP sequence and playback time here, which would actually be * helpful. But iOS doesn't, so we ignore it all together. */ - public synchronized void recordReceived(final ChannelHandlerContext ctx, final HttpRequest req) - throws Exception - { - if (m_audioStreamInformationProvider == null) + public synchronized void recordReceived(final ChannelHandlerContext ctx, final HttpRequest req) throws Exception { + if (audioStreamInformationProvider == null){ throw new ProtocolException("Audio stream not configured, cannot start recording"); - - s_logger.info("Client started streaming"); - + } + LOG.info("Client started streaming"); + + audioOutputQueue.startAudioProcessing(); + timingHandler.startTimeSync(); + final HttpResponse response = new DefaultHttpResponse(RtspVersions.RTSP_1_0, RtspResponseStatuses.OK); ctx.getChannel().write(response); } @@ -577,10 +691,11 @@ public synchronized void recordReceived(final ChannelHandlerContext ctx, final H * helpful. But iOS doesn't, so we ignore it all together. */ private synchronized void flushReceived(final ChannelHandlerContext ctx, final HttpRequest req) { - if (m_audioOutputQueue != null) - m_audioOutputQueue.flush(); + if (audioOutputQueue != null){ + audioOutputQueue.flush(); + } - s_logger.info("Client paused streaming, flushed audio output queue"); + LOG.info("Client paused streaming, flushed audio output queue"); final HttpResponse response = new DefaultHttpResponse(RtspVersions.RTSP_1_0, RtspResponseStatuses.OK); ctx.getChannel().write(response); @@ -596,7 +711,7 @@ private synchronized void teardownReceived(final ChannelHandlerContext ctx, fina @Override public void operationComplete(final ChannelFuture future) throws Exception { future.getChannel().close(); - s_logger.info("RTSP connection closed after client initiated teardown"); + LOG.info("RTSP connection closed after client initiated teardown"); } }); } @@ -612,9 +727,7 @@ public void operationComplete(final ChannelFuture future) throws Exception { /** * Handle SET_PARAMETER request. Currently only {@code volume} is supported */ - public synchronized void setParameterReceived(final ChannelHandlerContext ctx, final HttpRequest req) - throws ProtocolException - { + public synchronized void setParameterReceived(final ChannelHandlerContext ctx, final HttpRequest req) throws ProtocolException { /* Body in ASCII encoding with unix newlines */ final String body = req.getContent().toString(Charset.forName("ASCII")).replace("\r", ""); @@ -623,16 +736,18 @@ public synchronized void setParameterReceived(final ChannelHandlerContext ctx, f try { /* Split parameter into name and value */ final Matcher m_parameter = s_pattern_parameter.matcher(line); - if (!m_parameter.matches()) + if (!m_parameter.matches()){ throw new ProtocolException("Cannot parse line " + line); + } final String name = m_parameter.group(1); final String value = m_parameter.group(2); if ("volume".equals(name)) { /* Set output gain */ - if (m_audioOutputQueue != null) - m_audioOutputQueue.setGain(Float.parseFloat(value)); + if (audioOutputQueue != null){ + audioOutputQueue.setRequestedVolume(Float.parseFloat(value)); + } } } @@ -648,15 +763,13 @@ public synchronized void setParameterReceived(final ChannelHandlerContext ctx, f /** * Handle GET_PARAMETER request. Currently only {@code volume} is supported */ - public synchronized void getParameterReceived(final ChannelHandlerContext ctx, final HttpRequest req) - throws ProtocolException - { + public synchronized void getParameterReceived(final ChannelHandlerContext ctx, final HttpRequest req) throws ProtocolException { final StringBuilder body = new StringBuilder(); - if (m_audioOutputQueue != null) { + if (audioOutputQueue != null) { /* Report output gain */ body.append("volume: "); - body.append(m_audioOutputQueue.getGain()); + body.append(audioOutputQueue.getRequestedVolume()); body.append("\r\n"); } @@ -673,10 +786,12 @@ public synchronized void getParameterReceived(final ChannelHandlerContext ctx, f * @param channelType channel type. Determines which handlers are put into the pipeline * @return open data-gram channel */ - private Channel createRtpChannel(final SocketAddress local, final SocketAddress remote, final RaopRtpChannelType channelType) - { + private Channel createRtpChannel(final SocketAddress local, final SocketAddress remote, final RaopRtpChannelType channelType) { /* Create bootstrap helper for a data-gram socket using NIO */ - final ConnectionlessBootstrap bootstrap = new ConnectionlessBootstrap(new NioDatagramChannelFactory(m_rtpExecutorService)); + //final ConnectionlessBootstrap bootstrap = new ConnectionlessBootstrap(new NioDatagramChannelFactory(rtpExecutorService)); + final ConnectionlessBootstrap bootstrap = new ConnectionlessBootstrap(new OioDatagramChannelFactory(rtpExecutorService)); + + /* Set the buffer size predictor to 1500 bytes to ensure that * received packets will fit into the buffer. Packets are @@ -685,7 +800,7 @@ private Channel createRtpChannel(final SocketAddress local, final SocketAddress bootstrap.setOption("receiveBufferSizePredictorFactory", new FixedReceiveBufferSizePredictorFactory(1500)); /* Set the socket's receive buffer size. We set it to 1MB */ - bootstrap.setOption("receiveBufferSize", 1024*1024); + bootstrap.setOption("receiveBufferSize", 1024 * 1024); /* Set pipeline factory for the RTP channel */ bootstrap.setPipelineFactory(new ChannelPipelineFactory() { @@ -693,29 +808,38 @@ private Channel createRtpChannel(final SocketAddress local, final SocketAddress public ChannelPipeline getPipeline() throws Exception { final ChannelPipeline pipeline = Channels.pipeline(); - pipeline.addLast("executionHandler", AirReceiver.ChannelExecutionHandler); - pipeline.addLast("exceptionLogger", m_exceptionLoggingHandler); - pipeline.addLast("decoder", m_decodeHandler); - pipeline.addLast("encoder", m_encodeHandler); + final AirPlayServer airPlayServer = AirPlayServer.getIstance(); + + pipeline.addLast("executionHandler", airPlayServer.getChannelExecutionHandler()); + pipeline.addLast("exceptionLogger", exceptionLoggingHandler); + pipeline.addLast("decoder", decodeHandler); + pipeline.addLast("encoder", encodeHandler); + /* We pretend that all communication takes place on the audio channel, * and simply re-route packets from and to the control and timing channels */ - if (!channelType.equals(RaopRtpChannelType.Audio)) { - pipeline.addLast("inputToAudioRouter", m_inputToAudioRouterDownstreamHandler); + if ( ! channelType.equals(RaopRtpChannelType.Audio)) { + pipeline.addLast("inputToAudioRouter", inputToAudioRouterDownstreamHandler); + /* Must come *after* the router, otherwise incoming packets are logged twice */ - pipeline.addLast("packetLogger", m_packetLoggingHandler); + pipeline.addLast("packetLogger", packetLoggingHandler); } else { /* Must come *before* the router, otherwise outgoing packets are logged twice */ - pipeline.addLast("packetLogger", m_packetLoggingHandler); - pipeline.addLast("audioToOutputRouter", m_audioToOutputRouterUpstreamHandler); - pipeline.addLast("timing", m_timingHandler); - pipeline.addLast("resendRequester", m_resendRequestHandler); - if (m_decryptionHandler != null) - pipeline.addLast("decrypt", m_decryptionHandler); - if (m_audioDecodeHandler != null) - pipeline.addLast("audioDecode", m_audioDecodeHandler); - pipeline.addLast("enqueue", m_audioEnqueueHandler); + pipeline.addLast("packetLogger", packetLoggingHandler); + pipeline.addLast("audioToOutputRouter", audioToOutputRouterUpstreamHandler); + pipeline.addLast("timing", timingHandler); + pipeline.addLast("resendRequester", resendRequestHandler); + + if (decryptionHandler != null){ + pipeline.addLast("decrypt", decryptionHandler); + } + + if (audioDecodeHandler != null){ + pipeline.addLast("audioDecode", audioDecodeHandler); + } + + pipeline.addLast("enqueue", audioEnqueueHandler); } return pipeline; @@ -729,18 +853,20 @@ public ChannelPipeline getPipeline() throws Exception { channel = bootstrap.bind(local); /* Add to group of RTP channels beloging to this RTSP connection */ - m_rtpChannels.add(channel); + rtpChannels.add(channel); /* Connect to remote address if one was provided */ - if (remote != null) + if (remote != null){ channel.connect(remote); - + } + didThrow = false; return channel; } finally { - if (didThrow && (channel != null)) + if (didThrow && (channel != null)){ channel.close(); + } } } @@ -763,6 +889,7 @@ private InetSocketAddress substitutePort(final InetSocketAddress address, final * http://stackoverflow.com/questions/1512578/jvm-crash-on-opening-a-return-socketchannel * converting to address to a string first fixes the problem. */ - return new InetSocketAddress(address.getAddress().getHostAddress(), port); + //return new InetSocketAddress(address.getAddress().getHostAddress(), port); + return new InetSocketAddress(address.getAddress(), port); } } diff --git a/src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioAlacDecodeHandler.java b/src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioAlacDecodeHandler.java new file mode 100644 index 0000000..7c11269 --- /dev/null +++ b/src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioAlacDecodeHandler.java @@ -0,0 +1,215 @@ +/* + * This file is part of AirReceiver. + * + * AirReceiver is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * AirReceiver is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with AirReceiver. If not, see . + */ + +package nz.co.iswe.android.airplay.audio; + +import java.util.Arrays; +import java.util.logging.Level; +import java.util.logging.Logger; + +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket; + +import org.jboss.netty.channel.Channel; +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.handler.codec.oneone.OneToOneDecoder; +import org.phlo.AirReceiver.ProtocolException; + +import android.media.AudioFormat; + +import com.beatofthedrum.alacdecoder.AlacDecodeUtils; +import com.beatofthedrum.alacdecoder.AlacFile; + +/** + * Decodes the ALAC audio data in incoming audio packets to big endian unsigned PCM. + * Also serves as an {@link AudioStreamInformationProvider} + * + * This class assumes that ALAC requires no inter-packet state - it doesn't make + * any effort to feed the packets to ALAC in the correct order. + */ +public class RaopRtpAudioAlacDecodeHandler extends OneToOneDecoder implements AudioStreamInformationProvider { + + private static Logger LOG = Logger.getLogger(RaopRtpAudioAlacDecodeHandler.class.getName()); + + /* There are the indices into the SDP format options at which + * the sessions appear + */ + + public static final int FORMAT_OPTION_SAMPLES_PER_FRAME = 0; + public static final int FORMAT_OPTION_7a = 1; + public static final int FORMAT_OPTION_BITS_PER_SAMPLE = 2; + public static final int FORMAT_OPTION_RICE_HISTORY_MULT = 3; + public static final int FORMAT_OPTION_RICE_INITIAL_HISTORY = 4; + public static final int FORMAT_OPTION_RICE_KMODIFIER = 5; + public static final int FORMAT_OPTION_7f = 6; + public static final int FORMAT_OPTION_80 = 7; + public static final int FORMAT_OPTION_82 = 8; + public static final int FORMAT_OPTION_86 = 9; + public static final int FORMAT_OPTION_8a_RATE = 10; + + /** + * Number of samples per ALAC frame (packet). + * One sample here means *two* amplitues, one + * for the left channel and one for the right + */ + private final int samplesPerFrame; + + /* We support only 44100 kHz */ + private final int sampleRate = 44100; + + private final int channels = 2; + + //16 bit + private final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; + + private final int sampleSizeInBits = 16; + + /** + * Decoder state + */ + private final AlacFile alacFile; + + /** + * Creates an ALAC decoder instance from a list of format options as + * they appear in the SDP session announcement. + * + * @param formatOptions list of format options + * @throws ProtocolException if the format options are invalid for ALAC + */ + public RaopRtpAudioAlacDecodeHandler(final String[] formatOptions) throws ProtocolException { + + samplesPerFrame = Integer.valueOf(formatOptions[FORMAT_OPTION_SAMPLES_PER_FRAME]); + + /* We support only 16-bit ALAC */ + if ( Integer.valueOf(formatOptions[FORMAT_OPTION_BITS_PER_SAMPLE]) != sampleSizeInBits ) { + throw new ProtocolException("Sample size must be 16, but was " + Integer.valueOf(formatOptions[FORMAT_OPTION_BITS_PER_SAMPLE])); + } + + /* We support only 44100 kHz */ + int tempSampleRate = Integer.valueOf(formatOptions[FORMAT_OPTION_8a_RATE]); + if (tempSampleRate != getSampleRate()){ + throw new ProtocolException("Sample rate must be " + getSampleRate() + ", but was " + tempSampleRate); + } + + alacFile = AlacDecodeUtils.create_alac(getSampleSizeInBits(), getChannels()); + + alacFile.setinfo_max_samples_per_frame = samplesPerFrame; + alacFile.setinfo_7a = Integer.valueOf(formatOptions[FORMAT_OPTION_7a]); + alacFile.setinfo_sample_size = getSampleSizeInBits(); + alacFile.setinfo_rice_historymult = Integer.valueOf(formatOptions[FORMAT_OPTION_RICE_HISTORY_MULT]); + alacFile.setinfo_rice_initialhistory = Integer.valueOf(formatOptions[FORMAT_OPTION_RICE_INITIAL_HISTORY]); + alacFile.setinfo_rice_kmodifier = Integer.valueOf(formatOptions[FORMAT_OPTION_RICE_KMODIFIER]); + alacFile.setinfo_7f = Integer.valueOf(formatOptions[FORMAT_OPTION_7f]); + alacFile.setinfo_80 = Integer.valueOf(formatOptions[FORMAT_OPTION_80]); + alacFile.setinfo_82 = Integer.valueOf(formatOptions[FORMAT_OPTION_82]); + alacFile.setinfo_86 = Integer.valueOf(formatOptions[FORMAT_OPTION_86]); + alacFile.setinfo_8a_rate = sampleRate; + + LOG.info("Created ALAC decode for options " + Arrays.toString(formatOptions)); + } + + @Override + protected synchronized Object decode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) + throws Exception { + + //check for a Audio message + if ( ! (msg instanceof RaopRtpPacket.Audio)){ + return msg; + } + + final RaopRtpPacket.Audio alacPacket = (RaopRtpPacket.Audio)msg; + + /* The ALAC decode sometimes reads beyond the input's bounds + * (but later discards the data). To alleviate, we allocate + * 3 spare bytes at input buffer's end. + */ + final byte[] alacBytes = new byte[alacPacket.getPayload().capacity() + 3]; + alacPacket.getPayload().getBytes(0, alacBytes, 0, alacPacket.getPayload().capacity()); + + /* Decode ALAC to PCM */ + final int[] pcmSamples = new int[samplesPerFrame * 2]; + final int pcmSamplesBytes = AlacDecodeUtils.decode_frame(alacFile, alacBytes, pcmSamples, samplesPerFrame); + + /* decode_frame() returns the number of *bytes*, not samples! */ + final int pcmSamplesLength = pcmSamplesBytes / 4; + final Level level = Level.FINEST; + if (LOG.isLoggable(level)){ + LOG.log(level, "Decoded " + alacBytes.length + " bytes of ALAC audio data to " + pcmSamplesLength + " PCM samples"); + } + + /* Complain if the sender doesn't honour it's commitment */ + if (pcmSamplesLength != samplesPerFrame){ + throw new ProtocolException("Frame declared to contain " + samplesPerFrame + ", but contained " + pcmSamplesLength); + } + + /* Assemble PCM audio packet from original packet header and decoded data. + * The ALAC decode emits signed PCM samples as integers. We store them as + * as unsigned big endian integers in the packet. + */ + + RaopRtpPacket.Audio pcmPacket; + if (alacPacket instanceof RaopRtpPacket.AudioTransmit) { + pcmPacket = new RaopRtpPacket.AudioTransmit(pcmSamplesLength * 4); + alacPacket.getBuffer().getBytes(0, pcmPacket.getBuffer(), 0, RaopRtpPacket.AudioTransmit.LENGTH); + } + else if (alacPacket instanceof RaopRtpPacket.AudioRetransmit) { + pcmPacket = new RaopRtpPacket.AudioRetransmit(pcmSamplesLength * 4); + alacPacket.getBuffer().getBytes(0, pcmPacket.getBuffer(), 0, RaopRtpPacket.AudioRetransmit.LENGTH); + } + else{ + throw new ProtocolException("Packet type " + alacPacket.getClass() + " is not supported by the ALAC decoder"); + } + + //for each PCM sample + for(int i=0; i < pcmSamples.length; ++i) { + /* Convert sample to big endian unsigned integer PCM */ + final int pcmSampleUnsigned = pcmSamples[i] + 0x8000; + + pcmPacket.getPayload().setByte(2*i, (pcmSampleUnsigned & 0xff00) >> 8); + pcmPacket.getPayload().setByte(2*i + 1, pcmSampleUnsigned & 0x00ff); + } + + return pcmPacket; + } + + @Override + public int getFramesPerPacket() { + return samplesPerFrame; + } + + @Override + public double getPacketsPerSecond() { + //return getAudioFormat().getSampleRate() / (double)getFramesPerPacket(); + + return getSampleRate() / (double)getFramesPerPacket(); + } + + public int getSampleRate() { + return sampleRate; + } + + public int getChannels() { + return channels; + } + + public int getAudioFormat() { + return audioFormat; + } + + public int getSampleSizeInBits() { + return sampleSizeInBits; + } +} diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpAudioDecryptionHandler.java b/src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioDecryptionHandler.java similarity index 55% rename from src/main/java/org/phlo/AirReceiver/RaopRtpAudioDecryptionHandler.java rename to src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioDecryptionHandler.java index ca5dbde..c67d8f0 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpAudioDecryptionHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/audio/RaopRtpAudioDecryptionHandler.java @@ -15,25 +15,37 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.audio; + +import java.security.NoSuchAlgorithmException; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; -import org.jboss.netty.buffer.*; -import org.jboss.netty.channel.*; +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket; + +import org.jboss.netty.buffer.ChannelBuffer; +import org.jboss.netty.channel.Channel; +import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.handler.codec.oneone.OneToOneDecoder; /** * De-crypt AES encoded audio data */ public class RaopRtpAudioDecryptionHandler extends OneToOneDecoder { + + private static final Logger LOG = Logger.getLogger(RaopRtpAudioDecryptionHandler.class.getName()); + /** * The AES cipher. We request no padding because RAOP/AirTunes only encrypts full * block anyway and leaves the trailing byte unencrypted */ - private final Cipher m_aesCipher = AirTunesCrytography.getCipher("AES/CBC/NoPadding"); + //private final Cipher m_aesCipher = AirTunesCrytography.getCipher("AES/CBC/NoPadding"); + private Cipher aesCipher; /** * AES key */ @@ -47,12 +59,29 @@ public class RaopRtpAudioDecryptionHandler extends OneToOneDecoder { public RaopRtpAudioDecryptionHandler(final SecretKey aesKey, final IvParameterSpec aesIv) { m_aesKey = aesKey; m_aesIv = aesIv; + + + String transformation = "AES/CBC/NoPadding"; + try { + aesCipher = Cipher.getInstance(transformation); + + LOG.info("Cipher acquired sucessfully. transformation: " + transformation); + } + catch (NoSuchAlgorithmException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } + catch (NoSuchPaddingException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } + + } @Override protected synchronized Object decode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) - throws Exception - { + throws Exception { + + //check the message type if (msg instanceof RaopRtpPacket.Audio) { final RaopRtpPacket.Audio audioPacket = (RaopRtpPacket.Audio)msg; final ChannelBuffer audioPayload = audioPacket.getPayload(); @@ -60,11 +89,15 @@ protected synchronized Object decode(final ChannelHandlerContext ctx, final Chan /* Cipher is restarted for every packet. We simply overwrite the * encrypted data with the corresponding plain text */ - m_aesCipher.init(Cipher.DECRYPT_MODE, m_aesKey, m_aesIv); - for(int i=0; (i + 16) <= audioPayload.capacity(); i += 16) { - byte[] block = new byte[16]; + aesCipher.init(Cipher.DECRYPT_MODE, m_aesKey, m_aesIv); + + for(int i = 0; (i + 16) <= audioPayload.capacity(); i += 16) { + byte[] block = new byte[16];//buffer for decrypting the data + //copy the bytes to the buffer audioPayload.getBytes(i, block); - block = m_aesCipher.update(block); + //decrypt the 16 bytes block + block = aesCipher.update(block); + //set it back to the channel audioPayload.setBytes(i, block); } } diff --git a/src/main/java/org/phlo/AirReceiver/AirTunesCrytography.java b/src/main/java/nz/co/iswe/android/airplay/crypto/AirTunesCryptography.java similarity index 95% rename from src/main/java/org/phlo/AirReceiver/AirTunesCrytography.java rename to src/main/java/nz/co/iswe/android/airplay/crypto/AirTunesCryptography.java index d38bc56..0ab3e1d 100644 --- a/src/main/java/org/phlo/AirReceiver/AirTunesCrytography.java +++ b/src/main/java/nz/co/iswe/android/airplay/crypto/AirTunesCryptography.java @@ -15,7 +15,7 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.crypto; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -30,15 +30,13 @@ import javax.crypto.Cipher; import javax.crypto.CipherSpi; -public final class AirTunesCrytography { - /** - * Class is not meant to be instantiated - */ - private AirTunesCrytography() { - throw new RuntimeException(); - } +import org.phlo.AirReceiver.Base64; + +//TODO: Change it to a normal POJO without static methods.. +//user IoC to inject this class where necessary as a singleton +public final class AirTunesCryptography { - private static final Logger s_logger = Logger.getLogger(AirTunesCrytography.class.getName()); + private static final Logger LOG = Logger.getLogger(AirTunesCryptography.class.getName()); /** * The JCA/JCE Provider who supplies the necessary cryptographic algorithms @@ -84,11 +82,18 @@ private AirTunesCrytography() { public static final RSAPrivateKey PrivateKey = rsaPrivateKeyDecode(PrivateKeyData); static final Pattern s_transformation_pattern = Pattern.compile("^([A-Za-z0-9_.-]+)(/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+))?"); + + /** + * Class is not meant to be instantiated + */ + private AirTunesCryptography() { + throw new RuntimeException(); + } + /** * Replacement for JCA/JCE's {@link javax.crypto.Cipher#getInstance}. * The original method only accepts JCE providers from signed jars, - * which prevents us from bundling our cryptography provider Bouncy Caster - * with the application. + * which prevents us from bundling our cryptography provider Bouncy Caster with the application. * * @param transformation the transformation to find an implementation for */ @@ -156,7 +161,7 @@ else if (resolveProperty(Provider, "Cipher", algorithm) != null) { /* Create a {@link javax.crypto.Cipher} instance from the {@link javax.crypto.CipherSpi} the provider gave us */ - s_logger.info("Using SPI " + cipherSpi.getClass() + " for " + transformation); + LOG.info("Using SPI " + cipherSpi.getClass() + " for " + transformation); return getCipher(cipherSpi, transformation.toUpperCase()); } catch (final RuntimeException e) { @@ -197,6 +202,10 @@ private static RSAPrivateKey rsaPrivateKeyDecode(final String privateKey) { * @throws Throwable in case of an error */ private static Cipher getCipher(final CipherSpi cipherSpi, final String transformation) throws Throwable { + + //06-19 15:22:00.727: W/erverSocketPipelineSink(31810): Caused by: java.lang.NoSuchMethodException: [class javax.crypto.CipherSpi, class java.lang.String] + + /* This API isn't public - usually you're expected to simply use Cipher.getInstance(). * Due to the signed-jar restriction for JCE providers that is not an option, so we * use one of the private constructors of Cipher. diff --git a/src/main/java/org/phlo/AirReceiver/ExceptionLoggingHandler.java b/src/main/java/nz/co/iswe/android/airplay/network/ExceptionLoggingHandler.java similarity index 84% rename from src/main/java/org/phlo/AirReceiver/ExceptionLoggingHandler.java rename to src/main/java/nz/co/iswe/android/airplay/network/ExceptionLoggingHandler.java index 4145848..044000e 100644 --- a/src/main/java/org/phlo/AirReceiver/ExceptionLoggingHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/ExceptionLoggingHandler.java @@ -15,7 +15,7 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network; import java.util.logging.Level; import java.util.logging.Logger; @@ -26,11 +26,11 @@ * Logs exceptions thrown by other channel handlers */ public class ExceptionLoggingHandler extends SimpleChannelHandler { - private static Logger s_logger = Logger.getLogger(ExceptionLoggingHandler.class.getName()); + private static Logger LOG = Logger.getLogger(ExceptionLoggingHandler.class.getName()); @Override public void exceptionCaught(final ChannelHandlerContext ctx, final ExceptionEvent evt) throws Exception { super.exceptionCaught(ctx, evt); - s_logger.log(Level.WARNING, "Handler raised exception", evt.getCause()); + LOG.log(Level.WARNING, "Handler raised exception", evt.getCause()); } } diff --git a/src/main/java/nz/co/iswe/android/airplay/network/NetworkUtils.java b/src/main/java/nz/co/iswe/android/airplay/network/NetworkUtils.java new file mode 100644 index 0000000..1a72016 --- /dev/null +++ b/src/main/java/nz/co/iswe/android/airplay/network/NetworkUtils.java @@ -0,0 +1,128 @@ +package nz.co.iswe.android.airplay.network; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Arrays; +import java.util.Collections; +import java.util.logging.Logger; + +public class NetworkUtils { + + private static final Logger LOG = Logger.getLogger(NetworkUtils.class.getName()); + + private static NetworkUtils instance; + public static NetworkUtils getInstance(){ + if(instance == null){ + instance = new NetworkUtils(); + } + return instance; + } + + private NetworkUtils(){ + + } + + + /** + * Returns a suitable hardware address. + * + * @return a MAC address + */ + public byte[] getHardwareAddress() { + try { + /* Search network interfaces for an interface with a valid, non-blocked hardware address */ + for(final NetworkInterface iface: Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (iface.isLoopback()){ + continue; + } + if (iface.isPointToPoint()){ + continue; + } + + try { + final byte[] ifaceMacAddress = iface.getHardwareAddress(); + if ((ifaceMacAddress != null) && (ifaceMacAddress.length == 6) && !isBlockedHardwareAddress(ifaceMacAddress)) { + LOG.info("Hardware address is " + toHexString(ifaceMacAddress) + " (" + iface.getDisplayName() + ")"); + return Arrays.copyOfRange(ifaceMacAddress, 0, 6); + } + } + catch (final Throwable e) { + /* Ignore */ + } + } + } + catch (final Throwable e) { + /* Ignore */ + } + + /* Fallback to the IP address padded to 6 bytes */ + try { + final byte[] hostAddress = Arrays.copyOfRange(InetAddress.getLocalHost().getAddress(), 0, 6); + LOG.info("Hardware address is " + toHexString(hostAddress) + " (IP address)"); + return hostAddress; + } + catch (final Throwable e) { + /* Ignore */ + } + + /* Fallback to a constant */ + LOG.info("Hardware address is 00DEADBEEF00 (last resort)"); + return new byte[] {(byte)0x00, (byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF, (byte)0x00}; + } + + /** + * Converts an array of bytes to a hexadecimal string + * + * @param bytes array of bytes + * @return hexadecimal representation + */ + private String toHexString(final byte[] bytes) { + final StringBuilder s = new StringBuilder(); + for(final byte b: bytes) { + final String h = Integer.toHexString(0x100 | b); + s.append(h.substring(h.length() - 2, h.length()).toUpperCase()); + } + return s.toString(); + } + + /** + * Decides whether or nor a given MAC address is the address of some + * virtual interface, like e.g. VMware's host-only interface (server-side). + * + * @param addr a MAC address + * @return true if the MAC address is unsuitable as the device's hardware address + */ + public boolean isBlockedHardwareAddress(final byte[] addr) { + if ((addr[0] & 0x02) != 0) + /* Locally administered */ + return true; + else if ((addr[0] == 0x00) && (addr[1] == 0x50) && (addr[2] == 0x56)) + /* VMware */ + return true; + else if ((addr[0] == 0x00) && (addr[1] == 0x1C) && (addr[2] == 0x42)) + /* Parallels */ + return true; + else if ((addr[0] == 0x00) && (addr[1] == 0x25) && (addr[2] == (byte)0xAE)) + /* Microsoft */ + return true; + else + return false; + } + + public String getHostUtils() { + String hostName = "DroidAirPlay"; + try { + hostName = InetAddress.getLocalHost().getHostName().split("\\.")[0]; + } + catch (final Throwable e) { + //do nothing + } + return hostName; + } + + public String getHardwareAddressString() { + byte[] hardwareAddressBytes = getHardwareAddress(); + return toHexString(hardwareAddressBytes); + } + +} diff --git a/src/main/java/org/phlo/AirReceiver/RtpEncodeHandler.java b/src/main/java/nz/co/iswe/android/airplay/network/RtpEncodeHandler.java similarity index 83% rename from src/main/java/org/phlo/AirReceiver/RtpEncodeHandler.java rename to src/main/java/nz/co/iswe/android/airplay/network/RtpEncodeHandler.java index 73bfdde..2e5b2e6 100644 --- a/src/main/java/org/phlo/AirReceiver/RtpEncodeHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/RtpEncodeHandler.java @@ -15,7 +15,9 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network; + +import nz.co.iswe.android.airplay.network.rtp.RtpPacket; import org.jboss.netty.channel.*; import org.jboss.netty.handler.codec.oneone.OneToOneEncoder; @@ -25,12 +27,12 @@ */ public class RtpEncodeHandler extends OneToOneEncoder { @Override - protected Object encode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) - throws Exception - { - if (msg instanceof RtpPacket) + protected Object encode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) throws Exception { + if (msg instanceof RtpPacket){ return ((RtpPacket)msg).getBuffer(); - else + } + else{ return msg; + } } } diff --git a/src/main/java/org/phlo/AirReceiver/RtpLoggingHandler.java b/src/main/java/nz/co/iswe/android/airplay/network/RtpLoggingHandler.java similarity index 69% rename from src/main/java/org/phlo/AirReceiver/RtpLoggingHandler.java rename to src/main/java/nz/co/iswe/android/airplay/network/RtpLoggingHandler.java index cdeda05..0c08e6b 100644 --- a/src/main/java/org/phlo/AirReceiver/RtpLoggingHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/RtpLoggingHandler.java @@ -15,11 +15,14 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network; import java.util.logging.Level; import java.util.logging.Logger; +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket; +import nz.co.iswe.android.airplay.network.rtp.RtpPacket; + import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.channel.SimpleChannelHandler; @@ -28,46 +31,46 @@ * Logs incoming and outgoing RTP packets */ public class RtpLoggingHandler extends SimpleChannelHandler { - private static final Logger s_logger = Logger.getLogger(RtpLoggingHandler.class.getName()); + + private static final Logger LOG = Logger.getLogger(RtpLoggingHandler.class.getName()); @Override - public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) - throws Exception - { + public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { if (evt.getMessage() instanceof RtpPacket) { final RtpPacket packet = (RtpPacket)evt.getMessage(); - final Level level = getPacketLevel(packet); - if (s_logger.isLoggable(level)) - s_logger.log(level, evt.getRemoteAddress() + "> " + packet.toString()); + if (LOG.isLoggable(level)){ + LOG.log(level, evt.getRemoteAddress() + "> " + packet.toString()); + } } - super.messageReceived(ctx, evt); } @Override - public void writeRequested(final ChannelHandlerContext ctx, final MessageEvent evt) - throws Exception - { + public void writeRequested(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { if (evt.getMessage() instanceof RtpPacket) { final RtpPacket packet = (RtpPacket)evt.getMessage(); final Level level = getPacketLevel(packet); - if (s_logger.isLoggable(level)) - s_logger.log(level, evt.getRemoteAddress() + "< " + packet.toString()); + if (LOG.isLoggable(level)){ + LOG.log(level, evt.getRemoteAddress() + "< " + packet.toString()); + } } - super.writeRequested(ctx, evt); } private Level getPacketLevel(final RtpPacket packet) { - if (packet instanceof RaopRtpPacket.Audio) - return Level.FINEST; - else if (packet instanceof RaopRtpPacket.RetransmitRequest) + if (packet instanceof RaopRtpPacket.Audio){ return Level.FINEST; - else if (packet instanceof RaopRtpPacket.Timing) + } + else if (packet instanceof RaopRtpPacket.RetransmitRequest){ return Level.FINEST; - else + } + else if (packet instanceof RaopRtpPacket.Timing){ + return Level.INFO; + } + else{ return Level.FINE; + } } } diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpDecodeHandler.java b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpDecodeHandler.java similarity index 82% rename from src/main/java/org/phlo/AirReceiver/RaopRtpDecodeHandler.java rename to src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpDecodeHandler.java index c4255be..7a051b6 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpDecodeHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpDecodeHandler.java @@ -15,24 +15,25 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network.raop; import java.util.logging.Logger; + import org.jboss.netty.buffer.*; import org.jboss.netty.channel.*; import org.jboss.netty.handler.codec.oneone.OneToOneDecoder; +import org.phlo.AirReceiver.InvalidPacketException; /** * Decodes incoming packets, emitting instances of {@link RaopRtpPacket} */ public class RaopRtpDecodeHandler extends OneToOneDecoder { - private static final Logger s_logger = Logger.getLogger(RaopRtpDecodeHandler.class.getName()); + + private static final Logger LOG = Logger.getLogger(RaopRtpDecodeHandler.class.getName()); @Override - protected Object decode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) - throws Exception - { + protected Object decode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) throws Exception { if (msg instanceof ChannelBuffer) { final ChannelBuffer buffer = (ChannelBuffer)msg; @@ -40,7 +41,7 @@ protected Object decode(final ChannelHandlerContext ctx, final Channel channel, return RaopRtpPacket.decode(buffer); } catch (final InvalidPacketException e1) { - s_logger.warning(e1.getMessage()); + LOG.warning(e1.getMessage()); return buffer; } } diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpPacket.java b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpPacket.java similarity index 78% rename from src/main/java/org/phlo/AirReceiver/RaopRtpPacket.java rename to src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpPacket.java index c6c986a..04a516b 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpPacket.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpPacket.java @@ -15,14 +15,21 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network.raop; + +import java.util.logging.Logger; + +import nz.co.iswe.android.airplay.network.rtp.RtpPacket; import org.jboss.netty.buffer.ChannelBuffer; +import org.phlo.AirReceiver.ProtocolException; /** * Base class for the various RTP packet types of RAOP/AirTunes */ public abstract class RaopRtpPacket extends RtpPacket { + private static final Logger LOG = Logger.getLogger(RaopRtpPacket.class.getName()); + /** * Reads an 32-bit unsigned integer from a channel buffer * @param buffer the channel buffer @@ -85,29 +92,29 @@ public static void setBeUInt16(final ChannelBuffer buffer, final int index, fina * point number with 32 fractional bits. */ public static final class NtpTime { - public static final int Length = 8; + public static final int LENGTH = 8; - private final ChannelBuffer m_buffer; + private final ChannelBuffer buffer; protected NtpTime(final ChannelBuffer buffer) { - assert buffer.capacity() == Length; - m_buffer = buffer; + assert buffer.capacity() == LENGTH; + this.buffer = buffer; } public long getSeconds() { - return getBeUInt(m_buffer, 0); + return getBeUInt(buffer, 0); } public void setSeconds(final long seconds) { - setBeUInt(m_buffer, 0, seconds); + setBeUInt(buffer, 0, seconds); } public long getFraction() { - return getBeUInt(m_buffer, 4); + return getBeUInt(buffer, 4); } public void setFraction(final long fraction) { - setBeUInt(m_buffer, 4, fraction); + setBeUInt(buffer, 4, fraction); } public double getDouble() { @@ -124,10 +131,10 @@ public void setDouble(final double v) { * Base class for {@link TimingRequest} and {@link TimingResponse} */ public static class Timing extends RaopRtpPacket { - public static final int Length = RaopRtpPacket.Length + 4 + 8 + 8 + 8; + public static final int LENGTH = RaopRtpPacket.LENGTH + 4 + 8 + 8 + 8; protected Timing() { - super(Length); + super(LENGTH); setMarker(true); setSequence(7); } @@ -142,7 +149,7 @@ protected Timing(final ChannelBuffer buffer, final int minimumSize) throws Proto * @return */ public NtpTime getReferenceTime() { - return new NtpTime(getBuffer().slice(RaopRtpPacket.Length + 4, 8)); + return new NtpTime(getBuffer().slice(RaopRtpPacket.LENGTH + 4, 8)); } /** @@ -151,7 +158,7 @@ public NtpTime getReferenceTime() { * @return */ public NtpTime getReceivedTime() { - return new NtpTime(getBuffer().slice(RaopRtpPacket.Length + 12, 8)); + return new NtpTime(getBuffer().slice(RaopRtpPacket.LENGTH + 12, 8)); } /** @@ -160,7 +167,7 @@ public NtpTime getReceivedTime() { * @return */ public NtpTime getSendTime() { - return new NtpTime(getBuffer().slice(RaopRtpPacket.Length + 20, 8)); + return new NtpTime(getBuffer().slice(RaopRtpPacket.LENGTH + 20, 8)); } @Override @@ -186,14 +193,14 @@ public String toString() { * at least iOS ignores the packet. */ public static final class TimingRequest extends Timing { - public static final byte PayloadType = 0x52; + public static final byte PAYLOAD_TYPE = 0x52; public TimingRequest() { - setPayloadType(PayloadType); + setPayloadType(PAYLOAD_TYPE); } protected TimingRequest(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } } @@ -207,14 +214,14 @@ protected TimingRequest(final ChannelBuffer buffer) throws ProtocolException { * sequence, which is always 7. */ public static final class TimingResponse extends Timing { - public static final byte PayloadType = 0x53; + public static final byte PAYLOAD_TYPE = 0x53; public TimingResponse() { - setPayloadType(PayloadType); + setPayloadType(PAYLOAD_TYPE); } protected TimingResponse(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } } @@ -229,16 +236,16 @@ protected TimingResponse(final ChannelBuffer buffer) throws ProtocolException { * */ public static final class Sync extends RaopRtpPacket { - public static final byte PayloadType = 0x54; - public static final int Length = RaopRtpPacket.Length + 4 + 8 + 4; + public static final byte PAYLOAD_TYPE = 0x54; + public static final int LENGTH = RaopRtpPacket.LENGTH + 4 + 8 + 4; public Sync() { - super(Length); - setPayloadType(PayloadType); + super(LENGTH); + setPayloadType(PAYLOAD_TYPE); } protected Sync(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } /** @@ -248,7 +255,7 @@ protected Sync(final ChannelBuffer buffer) throws ProtocolException { * @return the source's RTP time corresponding to {@link #getTime()} minus the latency */ public long getTimeStampMinusLatency() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH); } /** @@ -258,7 +265,7 @@ public long getTimeStampMinusLatency() { * @return the source's RTP time corresponding to {@link #getTime()} minus the latency */ public void setTimeStampMinusLatency(final long value) { - setBeUInt(getBuffer(), RaopRtpPacket.Length, value); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH, value); } /** @@ -266,7 +273,7 @@ public void setTimeStampMinusLatency(final long value) { * @return the source's NTP time corresponding to the RTP time returned by {@link #getTimeStamp()} */ public NtpTime getTime() { - return new NtpTime(getBuffer().slice(RaopRtpPacket.Length + 4, 8)); + return new NtpTime(getBuffer().slice(RaopRtpPacket.LENGTH + 4, 8)); } /** @@ -276,7 +283,7 @@ public NtpTime getTime() { * @return the source's RTP time corresponding to {@link #getTime()} */ public long getTimeStamp() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length + 4 + 8); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4 + 8); } /** @@ -286,7 +293,7 @@ public long getTimeStamp() { * @param value the source's RTP time corresponding to {@link #getTime()} */ public void setTimeStamp(final long value) { - setBeUInt(getBuffer(), RaopRtpPacket.Length + 4 + 8, value); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4 + 8, value); } @Override @@ -316,18 +323,18 @@ public String toString() { * */ public static final class RetransmitRequest extends RaopRtpPacket { - public static final byte PayloadType = 0x55; - public static final int Length = RaopRtpPacket.Length + 4; + public static final byte PAYLOAD_TYPE = 0x55; + public static final int LENGTH = RaopRtpPacket.LENGTH + 4; public RetransmitRequest() { - super(Length); - setPayloadType(PayloadType); + super(LENGTH); + setPayloadType(PAYLOAD_TYPE); setMarker(true); setSequence(1); } protected RetransmitRequest(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } /** @@ -335,7 +342,7 @@ protected RetransmitRequest(final ChannelBuffer buffer) throws ProtocolException * @return sequence number */ public int getSequenceFirst() { - return getBeUInt16(getBuffer(), RaopRtpPacket.Length); + return getBeUInt16(getBuffer(), RaopRtpPacket.LENGTH); } /** @@ -343,7 +350,7 @@ public int getSequenceFirst() { * @param value sequence number */ public void setSequenceFirst(final int value) { - setBeUInt16(getBuffer(), RaopRtpPacket.Length, value); + setBeUInt16(getBuffer(), RaopRtpPacket.LENGTH, value); } /** @@ -351,7 +358,7 @@ public void setSequenceFirst(final int value) { * @return number of missing packets */ public int getSequenceCount() { - return getBeUInt16(getBuffer(), RaopRtpPacket.Length + 2); + return getBeUInt16(getBuffer(), RaopRtpPacket.LENGTH + 2); } /** @@ -359,7 +366,7 @@ public int getSequenceCount() { * @param value number of missing packets */ public void setSequenceCount(final int value) { - setBeUInt16(getBuffer(), RaopRtpPacket.Length + 2, value); + setBeUInt16(getBuffer(), RaopRtpPacket.LENGTH + 2, value); } @Override @@ -393,7 +400,7 @@ protected Audio(final ChannelBuffer buffer, final int minimumSize) throws Protoc abstract public long getTimeStamp(); /** - * Gets the packet's RTP time stamp (frame time) + * Sets the packet's RTP time stamp (frame time) * @param timeStamp RTP timestamp in frames */ abstract public void setTimeStamp(long timeStamp); @@ -421,43 +428,43 @@ protected Audio(final ChannelBuffer buffer, final int minimumSize) throws Protoc * Sent by the source (iTunes/iOS) on the audio channel. */ public static final class AudioTransmit extends Audio { - public static final byte PayloadType = 0x60; - public static final int Length = RaopRtpPacket.Length + 4 + 4; + public static final byte PAYLOAD_TYPE = 0x60; + public static final int LENGTH = RaopRtpPacket.LENGTH + 4 + 4; public AudioTransmit(final int payloadLength) { - super(Length + payloadLength); + super(LENGTH + payloadLength); assert payloadLength >= 0; - setPayloadType(PayloadType); + setPayloadType(PAYLOAD_TYPE); } protected AudioTransmit(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } @Override public long getTimeStamp() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH); } @Override public void setTimeStamp(final long timeStamp) { - setBeUInt(getBuffer(), RaopRtpPacket.Length, timeStamp); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH, timeStamp); } @Override public long getSSrc() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length + 4); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4); } @Override public void setSSrc(final long sSrc) { - setBeUInt(getBuffer(), RaopRtpPacket.Length + 4, sSrc); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4, sSrc); } @Override public ChannelBuffer getPayload() { - return getBuffer().slice(Length, getLength() - Length); + return getBuffer().slice(LENGTH, getLength() - LENGTH); } @Override @@ -480,32 +487,32 @@ public String toString() { * to {@link RetransmitRequest}. */ public static final class AudioRetransmit extends Audio { - public static final byte PayloadType = 0x56; - public static final int Length = RaopRtpPacket.Length + 4 + 4 + 4; + public static final byte PAYLOAD_TYPE = 0x56; + public static final int LENGTH = RaopRtpPacket.LENGTH + 4 + 4 + 4; public AudioRetransmit(final int payloadLength) { - super(Length + payloadLength); + super(LENGTH + payloadLength); assert payloadLength >= 0; - setPayloadType(PayloadType); + setPayloadType(PAYLOAD_TYPE); } protected AudioRetransmit(final ChannelBuffer buffer) throws ProtocolException { - super(buffer, Length); + super(buffer, LENGTH); } /** * First two bytes after RTP header */ public int getUnknown2Bytes() { - return getBeUInt16(getBuffer(), RaopRtpPacket.Length); + return getBeUInt16(getBuffer(), RaopRtpPacket.LENGTH); } /** * First two bytes after RTP header */ public void setUnknown2Bytes(final int b) { - setBeUInt16(getBuffer(), RaopRtpPacket.Length, b); + setBeUInt16(getBuffer(), RaopRtpPacket.LENGTH, b); } /** @@ -514,7 +521,7 @@ public void setUnknown2Bytes(final int b) { * retransmitted). */ public int getOriginalSequence() { - return getBeUInt16(getBuffer(), RaopRtpPacket.Length + 2); + return getBeUInt16(getBuffer(), RaopRtpPacket.LENGTH + 2); } /** @@ -523,32 +530,32 @@ public int getOriginalSequence() { * retransmitted). */ public void setOriginalSequence(final int seq) { - setBeUInt16(getBuffer(), RaopRtpPacket.Length + 2, seq); + setBeUInt16(getBuffer(), RaopRtpPacket.LENGTH + 2, seq); } @Override public long getTimeStamp() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length + 4); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4); } @Override public void setTimeStamp(final long timeStamp) { - setBeUInt(getBuffer(), RaopRtpPacket.Length + 4, timeStamp); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4, timeStamp); } @Override public long getSSrc() { - return getBeUInt(getBuffer(), RaopRtpPacket.Length + 4 + 4); + return getBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4 + 4); } @Override public void setSSrc(final long sSrc) { - setBeUInt(getBuffer(), RaopRtpPacket.Length + 4 + 4, sSrc); + setBeUInt(getBuffer(), RaopRtpPacket.LENGTH + 4 + 4, sSrc); } @Override public ChannelBuffer getPayload() { - return getBuffer().slice(Length, getLength() - Length); + return getBuffer().slice(LENGTH, getLength() - LENGTH); } @Override @@ -578,15 +585,18 @@ public String toString() { public static RaopRtpPacket decode(final ChannelBuffer buffer) throws ProtocolException { - final RtpPacket rtpPacket = new RtpPacket(buffer, Length); + final RtpPacket rtpPacket = new RtpPacket(buffer, LENGTH); + //add log here and compare between the java and android implementations + LOG.finest("decode packet. RtpPacket: " + rtpPacket); + switch (rtpPacket.getPayloadType()) { - case TimingRequest.PayloadType: return new TimingRequest(buffer); - case TimingResponse.PayloadType: return new TimingResponse(buffer); - case Sync.PayloadType: return new Sync(buffer); - case RetransmitRequest.PayloadType: return new RetransmitRequest(buffer); - case AudioRetransmit.PayloadType: return new AudioRetransmit(buffer); - case AudioTransmit.PayloadType: return new AudioTransmit(buffer); + case TimingRequest.PAYLOAD_TYPE: return new TimingRequest(buffer); + case TimingResponse.PAYLOAD_TYPE: return new TimingResponse(buffer); + case Sync.PAYLOAD_TYPE: return new Sync(buffer); + case RetransmitRequest.PAYLOAD_TYPE: return new RetransmitRequest(buffer); + case AudioRetransmit.PAYLOAD_TYPE: return new AudioRetransmit(buffer); + case AudioTransmit.PAYLOAD_TYPE: return new AudioTransmit(buffer); default: throw new ProtocolException("Invalid PayloadType " + rtpPacket.getPayloadType()); } } diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpTimingHandler.java b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpTimingHandler.java similarity index 58% rename from src/main/java/org/phlo/AirReceiver/RaopRtpTimingHandler.java rename to src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpTimingHandler.java index 3018041..c98e28c 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpTimingHandler.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtpTimingHandler.java @@ -15,11 +15,19 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network.raop; import java.util.logging.Logger; -import org.jboss.netty.channel.*; +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket.TimingRequest; + +import org.jboss.netty.channel.Channel; +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.channel.ChannelStateEvent; +import org.jboss.netty.channel.MessageEvent; +import org.jboss.netty.channel.SimpleChannelHandler; +import org.phlo.AirReceiver.AudioClock; +import org.phlo.AirReceiver.RunningExponentialAverage; /** * Handles RTP timing. @@ -29,34 +37,37 @@ * sync packet. */ public class RaopRtpTimingHandler extends SimpleChannelHandler { - private static Logger s_logger = Logger.getLogger(RaopRtpTimingHandler.class.getName()); + private static Logger LOG = Logger.getLogger(RaopRtpTimingHandler.class.getName()); /** * Number of seconds between {@link TimingRequest}s. */ - public static final double TimeRequestInterval = 0.2; + public static final double TIME_REQUEST_INTERVAL = 3; /** * Thread which sends out {@link TimingRequests}s. */ private class TimingRequester implements Runnable { - private final Channel m_channel; + private final Channel channel; public TimingRequester(final Channel channel) { - m_channel = channel; + this.channel = channel; } @Override public void run() { - while (!Thread.currentThread().isInterrupted()) { + while ( ! Thread.currentThread().isInterrupted() ) { final RaopRtpPacket.TimingRequest timingRequestPacket = new RaopRtpPacket.TimingRequest(); + timingRequestPacket.getReceivedTime().setDouble(0); /* Set by the source */ timingRequestPacket.getReferenceTime().setDouble(0); /* Set by the source */ - timingRequestPacket.getSendTime().setDouble(m_audioClock.getNowSecondsTime()); + timingRequestPacket.getSendTime().setDouble(audioClock.getNowSecondsTime()); - m_channel.write(timingRequestPacket); + LOG.info("sending timingRequestPacket: " + timingRequestPacket); + + channel.write(timingRequestPacket); try { - Thread.sleep(Math.round(TimeRequestInterval * 1000)); + Thread.sleep(Math.round(TIME_REQUEST_INTERVAL * 1000)); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); @@ -68,47 +79,55 @@ public void run() { /** * Audio time source */ - private final AudioClock m_audioClock; + private final AudioClock audioClock; /** * Exponential averager used to smooth the remote seconds offset */ - private final RunningExponentialAverage m_remoteSecondsOffset = new RunningExponentialAverage(); + private final RunningExponentialAverage averageRemoteSecondsOffset = new RunningExponentialAverage(); /** * The {@link TimingRequester} thread. */ - private Thread m_synchronizationThread; + private Thread synchronizationThread; + private boolean started = false; + public RaopRtpTimingHandler(final AudioClock audioClock) { - m_audioClock = audioClock; + this.audioClock = audioClock; } @Override - public void channelOpen(final ChannelHandlerContext ctx, final ChannelStateEvent evt) - throws Exception - { + public void channelOpen(final ChannelHandlerContext ctx, final ChannelStateEvent evt) throws Exception { + channelClosed(ctx, evt); - /* Start synchronization thread if it isn't already running */ - if (m_synchronizationThread == null) { - m_synchronizationThread = new Thread(new TimingRequester(ctx.getChannel())); - m_synchronizationThread.setDaemon(true); - m_synchronizationThread.setName("Time Synchronizer"); - m_synchronizationThread.start(); - s_logger.fine("Time synchronizer started"); + /* create synchronization thread if it isn't already running */ + if (synchronizationThread == null) { + synchronizationThread = new Thread(new TimingRequester(ctx.getChannel())); + synchronizationThread.setDaemon(true); + synchronizationThread.setName("Time Synchronizer"); } - + super.channelOpen(ctx, evt); } + + public synchronized void startTimeSync(){ + /* Start synchronization thread if it isn't already running */ + if (synchronizationThread != null && ! started) { + synchronizationThread.start(); + LOG.info("Time synchronizer started"); + started = true; + } + } @Override public void channelClosed(final ChannelHandlerContext ctx, final ChannelStateEvent evt) throws Exception { synchronized(this) { - if (m_synchronizationThread != null) - m_synchronizationThread.interrupt(); + if (synchronizationThread != null) + synchronizationThread.interrupt(); } } @@ -116,26 +135,26 @@ public void channelClosed(final ChannelHandlerContext ctx, final ChannelStateEve public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent evt) throws Exception { - if (evt.getMessage() instanceof RaopRtpPacket.Sync) + if (evt.getMessage() instanceof RaopRtpPacket.Sync){ syncReceived((RaopRtpPacket.Sync)evt.getMessage()); - else if (evt.getMessage() instanceof RaopRtpPacket.TimingResponse) + } + else if (evt.getMessage() instanceof RaopRtpPacket.TimingResponse){ timingResponseReceived((RaopRtpPacket.TimingResponse)evt.getMessage()); + } super.messageReceived(ctx, evt); } private synchronized void timingResponseReceived(final RaopRtpPacket.TimingResponse timingResponsePacket) { - final double localReceiveSecondsTime = m_audioClock.getNowSecondsTime(); + final double localReceiveSecondsTime = audioClock.getNowSecondsTime(); /* Compute remove seconds offset, assuming that the transmission times of * the timing requests and the timing response are equal */ - final double localSecondsTime = - localReceiveSecondsTime * 0.5 + - timingResponsePacket.getReferenceTime().getDouble() * 0.5; - final double remoteSecondsTime = - timingResponsePacket.getReceivedTime().getDouble() * 0.5 + - timingResponsePacket.getSendTime().getDouble() * 0.5; + final double localSecondsTime = localReceiveSecondsTime * 0.5 + timingResponsePacket.getReferenceTime().getDouble() * 0.5; + + final double remoteSecondsTime = timingResponsePacket.getReceivedTime().getDouble() * 0.5 + timingResponsePacket.getSendTime().getDouble() * 0.5; + final double remoteSecondsOffset = remoteSecondsTime - localSecondsTime; /* @@ -151,30 +170,31 @@ private synchronized void timingResponseReceived(final RaopRtpPacket.TimingRespo * 1e-3, and starts to decrease rapidly for transmission times significantly * larger than 1ms. */ - final double localInterval = - localReceiveSecondsTime - - timingResponsePacket.getReferenceTime().getDouble(); - final double remoteInterval = - timingResponsePacket.getSendTime().getDouble() - - timingResponsePacket.getReceivedTime().getDouble(); + final double localInterval = localReceiveSecondsTime - timingResponsePacket.getReferenceTime().getDouble(); + + final double remoteInterval = timingResponsePacket.getSendTime().getDouble() - timingResponsePacket.getReceivedTime().getDouble(); + final double transmissionTime = Math.max(localInterval - remoteInterval, 0); + final double weight = 1e-6 / (transmissionTime + 1e-3); /* Update exponential average */ - final double remoteSecondsOffsetPrevious = (!m_remoteSecondsOffset.isEmpty() ? m_remoteSecondsOffset.get() : 0.0); - m_remoteSecondsOffset.add(remoteSecondsOffset, weight); - final double secondsTimeAdjustment = m_remoteSecondsOffset.get() - remoteSecondsOffsetPrevious; + final double remoteSecondsOffsetPrevious = ( ! averageRemoteSecondsOffset.isEmpty() ? averageRemoteSecondsOffset.get() : 0.0); + averageRemoteSecondsOffset.add(remoteSecondsOffset, weight); + + final double secondsTimeAdjustment = averageRemoteSecondsOffset.get() - remoteSecondsOffsetPrevious; - s_logger.finest("Timing response with weight " + weight + " indicated offset " + remoteSecondsOffset + " thereby adjusting the averaged offset by " + secondsTimeAdjustment + " leading to the new averaged offset " + m_remoteSecondsOffset.get()); + LOG.info("Timing response with weight " + weight + " indicated offset " + remoteSecondsOffset + " thereby adjusting the averaged offset by " + secondsTimeAdjustment + " leading to the new averaged offset " + averageRemoteSecondsOffset.get()); } private synchronized void syncReceived(final RaopRtpPacket.Sync syncPacket) { - if (!m_remoteSecondsOffset.isEmpty()) { + LOG.info("sync received : " + syncPacket); + if ( ! averageRemoteSecondsOffset.isEmpty() ) { /* If the times are synchronized, we can correct for the transmission * time of the sync packet since it contains the time it was sent as * a source's NTP time. */ - m_audioClock.setFrameTime( + audioClock.setFrameTime( syncPacket.getTimeStampMinusLatency(), convertRemoteToLocalSecondsTime(syncPacket.getTime().getDouble()) ); @@ -183,11 +203,11 @@ private synchronized void syncReceived(final RaopRtpPacket.Sync syncPacket) { /* If the times aren't yet synchronized, we simply assume the sync * packet's transmission time is zero. */ - m_audioClock.setFrameTime( + audioClock.setFrameTime( syncPacket.getTimeStampMinusLatency(), 0.0 ); - s_logger.warning("Times synchronized, cannot correct latency of sync packet"); + LOG.warning("Times synchronized, cannot correct latency of sync packet"); } } @@ -199,6 +219,6 @@ private synchronized void syncReceived(final RaopRtpPacket.Sync syncPacket) { * @return local NTP time */ private double convertRemoteToLocalSecondsTime(final double remoteSecondsTime) { - return remoteSecondsTime - m_remoteSecondsOffset.get(); + return remoteSecondsTime - averageRemoteSecondsOffset.get(); } } diff --git a/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtspPipelineFactory.java b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtspPipelineFactory.java new file mode 100644 index 0000000..9964cbd --- /dev/null +++ b/src/main/java/nz/co/iswe/android/airplay/network/raop/RaopRtspPipelineFactory.java @@ -0,0 +1,73 @@ +/* + * This file is part of AirReceiver. + * + * AirReceiver is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * AirReceiver is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with AirReceiver. If not, see . + */ + +package nz.co.iswe.android.airplay.network.raop; + +import nz.co.iswe.android.airplay.AirPlayServer; +import nz.co.iswe.android.airplay.audio.RaopAudioHandler; +import nz.co.iswe.android.airplay.network.ExceptionLoggingHandler; +import nz.co.iswe.android.airplay.network.NetworkUtils; + +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.channel.ChannelPipeline; +import org.jboss.netty.channel.ChannelPipelineFactory; +import org.jboss.netty.channel.ChannelStateEvent; +import org.jboss.netty.channel.Channels; +import org.jboss.netty.channel.SimpleChannelUpstreamHandler; +import org.jboss.netty.handler.codec.rtsp.RtspRequestDecoder; +import org.jboss.netty.handler.codec.rtsp.RtspResponseEncoder; +import org.phlo.AirReceiver.RaopRtspChallengeResponseHandler; +import org.phlo.AirReceiver.RaopRtspHeaderHandler; +import org.phlo.AirReceiver.RaopRtspOptionsHandler; +import org.phlo.AirReceiver.RtspErrorResponseHandler; +import org.phlo.AirReceiver.RtspLoggingHandler; +import org.phlo.AirReceiver.RtspUnsupportedResponseHandler; + +/** + * Factory for AirTunes/RAOP RTSP channels + */ +public class RaopRtspPipelineFactory implements ChannelPipelineFactory { + + @Override + public ChannelPipeline getPipeline() throws Exception { + final ChannelPipeline pipeline = Channels.pipeline(); + + final AirPlayServer airPlayServer = AirPlayServer.getIstance(); + + pipeline.addLast("executionHandler", airPlayServer.getChannelExecutionHandler()); + pipeline.addLast("closeOnShutdownHandler", new SimpleChannelUpstreamHandler() { + @Override + public void channelOpen(final ChannelHandlerContext ctx, final ChannelStateEvent e) throws Exception { + airPlayServer.getChannelGroup().add(e.getChannel()); + super.channelOpen(ctx, e); + } + }); + pipeline.addLast("exceptionLogger", new ExceptionLoggingHandler()); + pipeline.addLast("decoder", new RtspRequestDecoder()); + pipeline.addLast("encoder", new RtspResponseEncoder()); + pipeline.addLast("logger", new RtspLoggingHandler()); + pipeline.addLast("errorResponse", new RtspErrorResponseHandler()); + pipeline.addLast("challengeResponse", new RaopRtspChallengeResponseHandler(NetworkUtils.getInstance().getHardwareAddress())); + pipeline.addLast("header", new RaopRtspHeaderHandler()); + pipeline.addLast("options", new RaopRtspOptionsHandler()); + pipeline.addLast("audio", new RaopAudioHandler(airPlayServer.getExecutorService())); + pipeline.addLast("unsupportedResponse", new RtspUnsupportedResponseHandler()); + + return pipeline; + } + +} diff --git a/src/main/java/org/phlo/AirReceiver/RtpPacket.java b/src/main/java/nz/co/iswe/android/airplay/network/rtp/RtpPacket.java similarity index 75% rename from src/main/java/org/phlo/AirReceiver/RtpPacket.java rename to src/main/java/nz/co/iswe/android/airplay/network/rtp/RtpPacket.java index 821e116..2780b7c 100644 --- a/src/main/java/org/phlo/AirReceiver/RtpPacket.java +++ b/src/main/java/nz/co/iswe/android/airplay/network/rtp/RtpPacket.java @@ -15,41 +15,44 @@ * along with AirReceiver. If not, see . */ -package org.phlo.AirReceiver; +package nz.co.iswe.android.airplay.network.rtp; import org.jboss.netty.buffer.*; +import org.phlo.AirReceiver.InvalidPacketException; +import org.phlo.AirReceiver.ProtocolException; /** * Basic RTP packet as described by RFC 3550 */ public class RtpPacket { - public static final int Length = 4; + public static final int LENGTH = 4; - final private ChannelBuffer m_buffer; + final private ChannelBuffer buffer; protected RtpPacket(final int size) { - assert size >= Length; - m_buffer = ChannelBuffers.buffer(size); - m_buffer.writeZero(size); + assert size >= LENGTH; + buffer = ChannelBuffers.buffer(size); + buffer.writeZero(size); setVersion((byte)2); } public RtpPacket(final ChannelBuffer buffer) throws ProtocolException { - m_buffer = buffer; + this.buffer = buffer; } public RtpPacket(final ChannelBuffer buffer, final int minimumSize) throws ProtocolException { this(buffer); - if (buffer.capacity() < minimumSize) + if (buffer.capacity() < minimumSize){ throw new InvalidPacketException("Packet had invalid size " + buffer.capacity() + " instead of at least " + minimumSize); + } } public ChannelBuffer getBuffer() { - return m_buffer; + return buffer; } public int getLength() { - return m_buffer.capacity(); + return buffer.capacity(); } /** @@ -57,7 +60,7 @@ public int getLength() { * @return RTP version number */ public byte getVersion() { - return (byte)((m_buffer.getByte(0) & (0xC0)) >> 6); + return (byte)((buffer.getByte(0) & (0xC0)) >> 6); } /** @@ -66,7 +69,7 @@ public byte getVersion() { */ public void setVersion(final byte version) { assert (version & ~0x03) == 0; - m_buffer.setByte(0, (m_buffer.getByte(0) & ~(0xC0)) | (version << 6)); + buffer.setByte(0, (buffer.getByte(0) & ~(0xC0)) | (version << 6)); } /** @@ -74,7 +77,7 @@ public void setVersion(final byte version) { * @return RTP padding flag */ public boolean getPadding() { - return (m_buffer.getByte(0) & (0x20)) != 0; + return (buffer.getByte(0) & (0x20)) != 0; } /** @@ -82,7 +85,7 @@ public boolean getPadding() { * @param padding RTP padding flag */ public void setPadding(final boolean padding) { - m_buffer.setByte(0, (m_buffer.getByte(0) & ~0x20) | (padding ? 0x20 : 0x00)); + buffer.setByte(0, (buffer.getByte(0) & ~0x20) | (padding ? 0x20 : 0x00)); } /** @@ -90,7 +93,7 @@ public void setPadding(final boolean padding) { * @return RTP padding flag */ public boolean getExtension() { - return (m_buffer.getByte(0) & (0x10)) != 0; + return (buffer.getByte(0) & (0x10)) != 0; } /** @@ -98,7 +101,7 @@ public boolean getExtension() { * @param padding RTP padding flag */ public void setExtension(final boolean extension) { - m_buffer.setByte(0, (m_buffer.getByte(0) & ~0x10) | (extension ? 0x10 : 0x00)); + buffer.setByte(0, (buffer.getByte(0) & ~0x10) | (extension ? 0x10 : 0x00)); } /** @@ -108,7 +111,7 @@ public void setExtension(final boolean extension) { * @return nubmer of CSRC ids */ public byte getCsrcCount() { - return (byte)(m_buffer.getByte(0) & (0x0f)); + return (byte)(buffer.getByte(0) & (0x0f)); } /** @@ -120,7 +123,7 @@ public byte getCsrcCount() { */ public void setCsrcCount(final byte csrcCount) { assert (csrcCount & ~0x0f) == 0; - m_buffer.setByte(0, (m_buffer.getByte(0) & ~0x0f) | csrcCount); + buffer.setByte(0, (buffer.getByte(0) & ~0x0f) | csrcCount); } /** @@ -128,7 +131,7 @@ public void setCsrcCount(final byte csrcCount) { * @param marker RTP marker flag */ public boolean getMarker() { - return (m_buffer.getByte(1) & (0x80)) != 0; + return (buffer.getByte(1) & (0x80)) != 0; } /** @@ -136,7 +139,7 @@ public boolean getMarker() { * @param marker RTP marker flag */ public void setMarker(final boolean marker) { - m_buffer.setByte(1, (m_buffer.getByte(1) & ~0x80) | (marker ? 0x80 : 0x00)); + buffer.setByte(1, (buffer.getByte(1) & ~0x80) | (marker ? 0x80 : 0x00)); } /** @@ -144,7 +147,7 @@ public void setMarker(final boolean marker) { * @return packet's payload type */ public byte getPayloadType() { - return (byte)(m_buffer.getByte(1) & (0x7f)); + return (byte)(buffer.getByte(1) & (0x7f)); } /** @@ -153,7 +156,7 @@ public byte getPayloadType() { */ public void setPayloadType(final byte payloadType) { assert (payloadType & ~0x7f) == 0; - m_buffer.setByte(1, (m_buffer.getByte(1) & ~0x7f) | payloadType); + buffer.setByte(1, (buffer.getByte(1) & ~0x7f) | payloadType); } /** @@ -162,8 +165,8 @@ public void setPayloadType(final byte payloadType) { */ public int getSequence() { return ( - ((m_buffer.getByte(2) & 0xff) << 8) | - ((m_buffer.getByte(3) & 0xff) << 0) + ((buffer.getByte(2) & 0xff) << 8) | + ((buffer.getByte(3) & 0xff) << 0) ); } @@ -173,8 +176,8 @@ public int getSequence() { */ public void setSequence(final int sequence) { assert (sequence & ~0xffff) == 0; - m_buffer.setByte(2, (sequence & 0xff00) >> 8); - m_buffer.setByte(3, (sequence & 0x00ff) >> 0); + buffer.setByte(2, (sequence & 0xff00) >> 8); + buffer.setByte(3, (sequence & 0x00ff) >> 0); } @Override diff --git a/src/main/java/org/phlo/AirReceiver/AirReceiver.java b/src/main/java/org/phlo/AirReceiver/AirReceiver.java index b6abd8e..f585950 100644 --- a/src/main/java/org/phlo/AirReceiver/AirReceiver.java +++ b/src/main/java/org/phlo/AirReceiver/AirReceiver.java @@ -17,28 +17,52 @@ package org.phlo.AirReceiver; +import java.awt.Button; +import java.awt.Dialog; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.TextArea; +import java.awt.TrayIcon; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; import java.io.IOException; import java.io.InputStream; -import java.net.*; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.URL; import java.util.Arrays; import java.util.Collections; -import java.util.Map; import java.util.List; +import java.util.Map; import java.util.Properties; -import java.util.concurrent.*; -import java.util.logging.*; -import java.awt.*; -import java.awt.event.*; - -import javax.swing.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; -import javax.jmdns.*; +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceInfo; +import javax.swing.ImageIcon; import org.jboss.netty.bootstrap.ServerBootstrap; -import org.jboss.netty.channel.*; -import org.jboss.netty.channel.group.*; -import org.jboss.netty.channel.socket.nio.*; -import org.jboss.netty.handler.execution.*; +import org.jboss.netty.channel.ChannelHandler; +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.channel.ChannelStateEvent; +import org.jboss.netty.channel.SimpleChannelUpstreamHandler; +import org.jboss.netty.channel.group.ChannelGroup; +import org.jboss.netty.channel.group.ChannelGroupFuture; +import org.jboss.netty.channel.group.DefaultChannelGroup; +import org.jboss.netty.channel.socket.nio.NioServerSocketChannelFactory; +import org.jboss.netty.handler.execution.ExecutionHandler; +import org.jboss.netty.handler.execution.OrderedMemoryAwareThreadPoolExecutor; public class AirReceiver { /* Load java.util.logging configuration */ diff --git a/src/main/java/org/phlo/AirReceiver/AudioClock.java b/src/main/java/org/phlo/AirReceiver/AudioClock.java index 9864f84..5b8f125 100644 --- a/src/main/java/org/phlo/AirReceiver/AudioClock.java +++ b/src/main/java/org/phlo/AirReceiver/AudioClock.java @@ -33,7 +33,7 @@ public interface AudioClock { * * @return time of currently played sample */ - long getNowFrameTime(); + //long getNowFrameTime(); /** * Returns the earliest time in samples for which data diff --git a/src/main/java/org/phlo/AirReceiver/AudioOutputQueue.java b/src/main/java/org/phlo/AirReceiver/AudioOutputQueue.java deleted file mode 100644 index 5164e42..0000000 --- a/src/main/java/org/phlo/AirReceiver/AudioOutputQueue.java +++ /dev/null @@ -1,541 +0,0 @@ -/* - * This file is part of AirReceiver. - * - * AirReceiver is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * AirReceiver is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with AirReceiver. If not, see . - */ - -package org.phlo.AirReceiver; - -import java.util.Arrays; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.logging.Level; -import java.util.logging.Logger; - -import javax.sound.sampled.*; - -/** - * Audio output queue. - * - * Serves an an {@link AudioClock} and allows samples to be queued - * for playback at a specific time. - */ -public class AudioOutputQueue implements AudioClock { - private static Logger s_logger = Logger.getLogger(AudioOutputQueue.class.getName()); - - private static final double QueueLengthMaxSeconds = 10; - private static final double BufferSizeSeconds = 0.05; - private static final double TimingPrecision = 0.001; - - /** - * Signals that the queue is being closed. - * Never transitions from true to false! - */ - private volatile boolean m_closing = false; - - /** - * The line's audio format - */ - private final AudioFormat m_format; - - /** - * True if the line's audio format is signed but - * the requested format was unsigned - */ - private final boolean m_convertUnsignedToSigned; - - /** - * Bytes per frame, i.e. number of bytes - * per sample times the number of channels - */ - private final int m_bytesPerFrame; - - /** - * Sample rate - */ - private final double m_sampleRate; - - /** - * Average packet size in frames. - * We use this as the number of silence frames - * to write on a queue underrun - */ - private final int m_packetSizeFrames; - - /** - * JavaSounds audio output line - */ - private final SourceDataLine m_line; - - /** - * The last frame written to the line. - * Used to generate filler data - */ - private final byte[] m_lineLastFrame; - - /** - * Packet queue, indexed by playback time - */ - private final ConcurrentSkipListMap m_queue = new ConcurrentSkipListMap(); - - /** - * Enqueuer thread - */ - private final Thread m_queueThread = new Thread(new EnQueuer()); - - /** - * Number of frames appended to the line - */ - private long m_lineFramesWritten = 0; - - /** - * Largest frame time seen so far - */ - private long m_latestSeenFrameTime = 0; - - /** - * The frame time corresponding to line time zero - */ - private long m_frameTimeOffset = 0; - - /** - * The seconds time corresponding to line time zero - */ - private final double m_secondsTimeOffset; - - /** - * Requested line gain - */ - private float m_requestedGain = 0.0f; - - /** - * Enqueuer thread - */ - private class EnQueuer implements Runnable { - /** - * Enqueuer thread main method - */ - @Override - public void run() { - try { - /* Mute line initially to prevent clicks */ - setLineGain(Float.NEGATIVE_INFINITY); - - /* Start the line */ - m_line.start(); - - boolean lineMuted = true; - boolean didWarnGap = false; - while (!m_closing) { - if (!m_queue.isEmpty()) { - /* Queue filled */ - - /* If the gap between the next packet and the end of line is - * negligible (less than one packet), we write it to the line. - * Otherwise, we fill the line buffer with silence and hope for - * further packets to appear in the queue - */ - final long entryFrameTime = m_queue.firstKey(); - final long entryLineTime = convertFrameToLineTime(entryFrameTime); - final long gapFrames = entryLineTime - getNextLineTime(); - if (gapFrames < -m_packetSizeFrames) { - /* Too late for playback */ - s_logger.warning("Audio data was scheduled for playback " + (-gapFrames) + " frames ago, skipping"); - - m_queue.remove(entryFrameTime); - continue; - } - else if (gapFrames < m_packetSizeFrames) { - /* Negligible gap between packet and line end. Prepare packet for playback */ - didWarnGap = false; - - /* Unmute line in case it was muted previously */ - if (lineMuted) { - s_logger.info("Audio data available, un-muting line"); - - lineMuted = false; - applyGain(); - } - else if (getLineGain() != m_requestedGain) { - applyGain(); - } - - /* Get sample data and do sanity checks */ - final byte[] nextPlaybackSamples = m_queue.remove(entryFrameTime); - int nextPlaybackSamplesLength = nextPlaybackSamples.length; - if (nextPlaybackSamplesLength % m_bytesPerFrame != 0) { - s_logger.severe("Audio data contains non-integral number of frames, ignore last " + (nextPlaybackSamplesLength % m_bytesPerFrame) + " bytes"); - - nextPlaybackSamplesLength -= nextPlaybackSamplesLength % m_bytesPerFrame; - } - - /* Append packet to line */ - s_logger.finest("Audio data containing " + nextPlaybackSamplesLength / m_bytesPerFrame + " frames for playback time " + entryFrameTime + " found in queue, appending to the output line"); - appendFrames(nextPlaybackSamples, 0, nextPlaybackSamplesLength, entryLineTime); - continue; - } - else { - /* Gap between packet and line end. Warn */ - - if (!didWarnGap) { - didWarnGap = true; - s_logger.warning("Audio data missing for frame time " + getNextLineTime() + " (currently " + gapFrames + " frames), writing " + m_packetSizeFrames + " frames of silence"); - } - } - } - else { - /* Queue empty */ - - if (!lineMuted) { - lineMuted = true; - setLineGain(Float.NEGATIVE_INFINITY); - s_logger.fine("Audio data ended at frame time " + getNextLineTime() + ", writing " + m_packetSizeFrames + " frames of silence and muted line"); - } - } - - appendSilence(m_packetSizeFrames); - } - - /* Before we exit, we fill the line's buffer with silence. This should prevent - * noise from being output while the line is being stopped - */ - appendSilence(m_line.available() / m_bytesPerFrame); - } - catch (final Throwable e) { - s_logger.log(Level.SEVERE, "Audio output thread died unexpectedly", e); - } - finally { - setLineGain(Float.NEGATIVE_INFINITY); - m_line.stop(); - m_line.close(); - } - } - - /** - * Append the range [off,off+len) from the provided sample data to the line. - * If the requested playback time does not match the line end time, samples are - * skipped or silence is inserted as necessary. If the data is marked as being - * just a filler, some warnings are suppressed. - * - * @param samples sample data - * @param off sample data offset - * @param len sample data length - * @param time playback time - * @param warnNonContinous warn about non-continous samples - */ - private void appendFrames(final byte[] samples, int off, final int len, long lineTime) { - assert off % m_bytesPerFrame == 0; - assert len % m_bytesPerFrame == 0; - - while (true) { - /* Fetch line end time only once per iteration */ - final long endLineTime = getNextLineTime(); - - final long timingErrorFrames = lineTime - endLineTime; - final double timingErrorSeconds = timingErrorFrames / m_sampleRate; - - if (Math.abs(timingErrorSeconds) <= TimingPrecision) { - /* Samples to append scheduled exactly at line end. Just append them and be done */ - - appendFrames(samples, off, len); - break; - } - else if (timingErrorFrames > 0) { - /* Samples to append scheduled after the line end. Fill the gap with silence */ - s_logger.warning("Audio output non-continous (gap of " + timingErrorFrames + " frames), filling with silence"); - - appendSilence((int)(lineTime - endLineTime)); - } - else if (timingErrorFrames < 0) { - /* Samples to append scheduled before the line end. Remove the overlapping - * part and retry - */ - s_logger.warning("Audio output non-continous (overlap of " + (-timingErrorFrames) + "), skipping overlapping frames"); - - off += (endLineTime - lineTime) * m_bytesPerFrame; - lineTime += endLineTime - lineTime; - } - else { - /* Strange universe... */ - assert false; - } - } - } - - private void appendSilence(final int frames) { - final byte[] silenceFrames = new byte[frames * m_bytesPerFrame]; - for(int i = 0; i < silenceFrames.length; ++i) - silenceFrames[i] = m_lineLastFrame[i % m_bytesPerFrame]; - appendFrames(silenceFrames, 0, silenceFrames.length); - } - - /** - * Append the range [off,off+len) from the provided sample data to the line. - * - * @param samples sample data - * @param off sample data offset - * @param len sample data length - */ - private void appendFrames(final byte[] samples, int off, int len) { - assert off % m_bytesPerFrame == 0; - assert len % m_bytesPerFrame == 0; - - /* Make sure that [off,off+len) does not exceed sample's bounds */ - off = Math.min(off, (samples != null) ? samples.length : 0); - len = Math.min(len, (samples != null) ? samples.length - off : 0); - if (len <= 0) - return; - - /* Convert samples if necessary */ - final byte[] samplesConverted = Arrays.copyOfRange(samples, off, off+len); - if (m_convertUnsignedToSigned) { - /* The line expects signed PCM samples, so we must - * convert the unsigned PCM samples to signed. - * Note that this only affects the high bytes! - */ - for(int i=0; i < samplesConverted.length; i += 2) - samplesConverted[i] = (byte)((samplesConverted[i] & 0xff) - 0x80); - } - - /* Write samples to line */ - final int bytesWritten = m_line.write(samplesConverted, 0, samplesConverted.length); - if (bytesWritten != len) - s_logger.warning("Audio output line accepted only " + bytesWritten + " bytes of sample data while trying to write " + samples.length + " bytes"); - - /* Update state */ - synchronized(AudioOutputQueue.this) { - m_lineFramesWritten += bytesWritten / m_bytesPerFrame; - for(int b=0; b < m_bytesPerFrame; ++b) - m_lineLastFrame[b] = samples[off + len - (m_bytesPerFrame - b)]; - - s_logger.finest("Audio output line end is now at " + getNextLineTime() + " after writing " + len / m_bytesPerFrame + " frames"); - } - } - } - - AudioOutputQueue(final AudioStreamInformationProvider streamInfoProvider) throws LineUnavailableException { - final AudioFormat audioFormat = streamInfoProvider.getAudioFormat(); - - /* OSX does not support unsigned PCM lines. We thust always request - * a signed line, and convert from unsigned to signed if necessary - */ - if (AudioFormat.Encoding.PCM_SIGNED.equals(audioFormat.getEncoding())) { - m_format = audioFormat; - m_convertUnsignedToSigned = false; - } - else if (AudioFormat.Encoding.PCM_UNSIGNED.equals(audioFormat.getEncoding())) { - m_format = new AudioFormat( - audioFormat.getSampleRate(), - audioFormat.getSampleSizeInBits(), - audioFormat.getChannels(), - true, - audioFormat.isBigEndian() - ); - m_convertUnsignedToSigned = true; - } - else { - throw new LineUnavailableException("Audio encoding " + audioFormat.getEncoding() + " is not supported"); - } - - /* Audio format-dependent stuff */ - m_packetSizeFrames = streamInfoProvider.getFramesPerPacket(); - m_bytesPerFrame = m_format.getChannels() * m_format.getSampleSizeInBits() / 8; - m_sampleRate = m_format.getSampleRate(); - m_lineLastFrame = new byte[m_bytesPerFrame]; - for(int b=0; b < m_lineLastFrame.length; ++b) - m_lineLastFrame[b] = (b % 2 == 0) ? (byte)-128 : (byte)0; - - /* Compute desired line buffer size and obtain a line */ - final int desiredBufferSize = (int)Math.pow(2, Math.ceil(Math.log(BufferSizeSeconds * m_sampleRate * m_bytesPerFrame) / Math.log(2.0))); - final DataLine.Info lineInfo = new DataLine.Info( - SourceDataLine.class, - m_format, - desiredBufferSize - ); - m_line = (SourceDataLine)AudioSystem.getLine(lineInfo); - m_line.open(m_format, desiredBufferSize); - s_logger.info("Audio output line created and openend. Requested buffer of " + desiredBufferSize / m_bytesPerFrame + " frames, got " + m_line.getBufferSize() / m_bytesPerFrame + " frames"); - - /* Start enqueuer thread and wait for the line to start. - * The wait guarantees that the AudioClock functions return - * sensible values right after construction - */ - m_queueThread.setDaemon(true); - m_queueThread.setName("Audio Enqueuer"); - m_queueThread.setPriority(Thread.MAX_PRIORITY); - m_queueThread.start(); - while (m_queueThread.isAlive() && !m_line.isActive()) - Thread.yield(); - - /* Initialize the seconds time offset now that the line is running. */ - m_secondsTimeOffset = 2208988800.0 + System.currentTimeMillis() * 1e-3; - } - - /** - * Sets the line's MASTER_GAIN control to the provided value, - * or complains to the log of the line does not support a MASTER_GAIN control - * - * @param gain gain to set - */ - private void setLineGain(final float gain) { - if (m_line.isControlSupported(FloatControl.Type.MASTER_GAIN)) { - /* Bound gain value by min and max declared by the control */ - final FloatControl gainControl = (FloatControl)m_line.getControl(FloatControl.Type.MASTER_GAIN); - if (gain < gainControl.getMinimum()) - gainControl.setValue(gainControl.getMinimum()); - else if (gain > gainControl.getMaximum()) - gainControl.setValue(gainControl.getMaximum()); - else - gainControl.setValue(gain); - } - else - s_logger.severe("Audio output line doesn not support volume control"); - } - - /** - * Returns the line's MASTER_GAIN control's value. - */ - private float getLineGain() { - if (m_line.isControlSupported(FloatControl.Type.MASTER_GAIN)) { - /* Bound gain value by min and max declared by the control */ - final FloatControl gainControl = (FloatControl)m_line.getControl(FloatControl.Type.MASTER_GAIN); - return gainControl.getValue(); - } - else { - s_logger.severe("Audio output line doesn not support volume control"); - return 0.0f; - } - } - - private synchronized void applyGain() { - setLineGain(m_requestedGain); - } - - /** - * Sets the desired output gain. - * - * @param gain desired gain - */ - public synchronized void setGain(final float gain) { - m_requestedGain = gain; - } - - /** - * Returns the desired output gain. - * - * @param gain desired gain - */ - public synchronized float getGain() { - return m_requestedGain; - } - - /** - * Stops audio output - */ - public void close() { - m_closing = true; - m_queueThread.interrupt(); - } - - /** - * Adds sample data to the queue - * - * @param playbackRemoteStartFrameTime start time of sample data - * @param playbackSamples sample data - * @return true if the sample data was added to the queue - */ - public synchronized boolean enqueue(final long frameTime, final byte[] frames) { - /* Playback time of packet */ - final double packetSeconds = (double)frames.length / (double)(m_bytesPerFrame * m_sampleRate); - - /* Compute playback delay, i.e., the difference between the last sample's - * playback time and the current line time - */ - final double delay = - (convertFrameToLineTime(frameTime) + frames.length / m_bytesPerFrame - getNextLineTime()) / - m_sampleRate; - - m_latestSeenFrameTime = Math.max(m_latestSeenFrameTime, frameTime); - - if (delay < -packetSeconds) { - /* The whole packet is scheduled to be played in the past */ - s_logger.warning("Audio data arrived " + -(delay) + " seconds too late, dropping"); - return false; - } - else if (delay > QueueLengthMaxSeconds) { - /* The packet extends further into the future that our maximum queue size. - * We reject it, since this is probably the result of some timing discrepancies - */ - s_logger.warning("Audio data arrived " + delay + " seconds too early, dropping"); - return false; - } - - m_queue.put(frameTime, frames); - return true; - } - - /** - * Removes all currently queued sample data - */ - public void flush() { - m_queue.clear(); - } - - @Override - public synchronized void setFrameTime(final long frameTime, final double secondsTime) { - final double ageSeconds = getNowSecondsTime() - secondsTime; - final long lineTime = Math.round((secondsTime - m_secondsTimeOffset) * m_sampleRate); - - final long frameTimeOffsetPrevious = m_frameTimeOffset; - m_frameTimeOffset = frameTime - lineTime; - - s_logger.fine("Frame time adjusted by " + (m_frameTimeOffset - frameTimeOffsetPrevious) + " based on timing information " + ageSeconds + " seconds old and " + (m_latestSeenFrameTime - frameTime) + " frames before latest seen frame time"); - } - - @Override - public double getNowSecondsTime() { - return m_secondsTimeOffset + getNowLineTime() / m_sampleRate; - } - - @Override - public long getNowFrameTime() { - return m_frameTimeOffset + getNowLineTime(); - } - - @Override - public double getNextSecondsTime() { - return m_secondsTimeOffset + getNextLineTime() / m_sampleRate; - } - - @Override - public long getNextFrameTime() { - return m_frameTimeOffset + getNextLineTime(); - } - - @Override - public double convertFrameToSecondsTime(final long frameTime) { - return m_secondsTimeOffset + (frameTime - m_frameTimeOffset) / m_sampleRate; - } - - private synchronized long getNextLineTime() { - return m_lineFramesWritten; - } - - private long getNowLineTime() { - return m_line.getLongFramePosition(); - } - - private synchronized long convertFrameToLineTime(final long entryFrameTime) { - return entryFrameTime - m_frameTimeOffset; - } -} diff --git a/src/main/java/org/phlo/AirReceiver/ProtocolException.java b/src/main/java/org/phlo/AirReceiver/ProtocolException.java index 6642c26..0a03910 100644 --- a/src/main/java/org/phlo/AirReceiver/ProtocolException.java +++ b/src/main/java/org/phlo/AirReceiver/ProtocolException.java @@ -19,7 +19,7 @@ @SuppressWarnings("serial") public class ProtocolException extends Exception { - ProtocolException(final String message) { + public ProtocolException(final String message) { super(message); } } diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpAudioAlacDecodeHandler.java b/src/main/java/org/phlo/AirReceiver/RaopRtpAudioAlacDecodeHandler.java deleted file mode 100644 index ccdd285..0000000 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpAudioAlacDecodeHandler.java +++ /dev/null @@ -1,189 +0,0 @@ -/* - * This file is part of AirReceiver. - * - * AirReceiver is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * AirReceiver is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with AirReceiver. If not, see . - */ - -package org.phlo.AirReceiver; - -import java.util.Arrays; -import java.util.logging.*; - -import javax.sound.sampled.AudioFormat; - -import org.jboss.netty.channel.*; -import org.jboss.netty.handler.codec.oneone.OneToOneDecoder; - -import com.beatofthedrum.alacdecoder.*; - -/** - * Decodes the ALAC audio data in incoming audio packets to big endian unsigned PCM. - * Also serves as an {@link AudioStreamInformationProvider} - * - * This class assumes that ALAC requires no inter-packet state - it doesn't make - * any effort to feed the packets to ALAC in the correct order. - */ -public class RaopRtpAudioAlacDecodeHandler extends OneToOneDecoder implements AudioStreamInformationProvider { - private static Logger s_logger = Logger.getLogger(RaopRtpAudioAlacDecodeHandler.class.getName()); - - /* There are the indices into the SDP format options at which - * the sessions appear - */ - - public static final int FormatOptionSamplesPerFrame = 0; - public static final int FormatOption7a = 1; - public static final int FormatOptionBitsPerSample = 2; - public static final int FormatOptionRiceHistoryMult = 3; - public static final int FormatOptionRiceInitialHistory = 4; - public static final int FormatOptionRiceKModifier = 5; - public static final int FormatOption7f = 6; - public static final int FormatOption80 = 7; - public static final int FormatOption82 = 8; - public static final int FormatOption86 = 9; - public static final int FormatOption8a_rate = 10; - - /** - * The {@link AudioFormat} that corresponds to the output produced by the decoder - */ - private static final AudioFormat AudioOutputFormat = new AudioFormat( - 44100 /* sample rate */, - 16 /* bits per sample */, - 2 /* number of channels */, - false /* unsigned */, - true /* big endian */ - ); - - /** - * Number of samples per ALAC frame (packet). - * One sample here means *two* amplitues, one - * for the left channel and one for the right - */ - private final int m_samplesPerFrame; - - /** - * Decoder state - */ - private final AlacFile m_alacFile; - - /** - * Creates an ALAC decoder instance from a list of format options as - * they appear in the SDP session announcement. - * - * @param formatOptions list of format options - * @throws ProtocolException if the format options are invalid for ALAC - */ - public RaopRtpAudioAlacDecodeHandler(final String[] formatOptions) - throws ProtocolException - { - m_samplesPerFrame = Integer.valueOf(formatOptions[FormatOptionSamplesPerFrame]); - - /* We support only 16-bit ALAC */ - final int bitsPerSample = Integer.valueOf(formatOptions[FormatOptionBitsPerSample]); - if (bitsPerSample != 16) - throw new ProtocolException("Sample size must be 16, but was " + bitsPerSample); - - /* We support only 44100 kHz */ - final int sampleRate = Integer.valueOf(formatOptions[FormatOption8a_rate]); - if (sampleRate != 44100) - throw new ProtocolException("Sample rate must be 44100, but was " + sampleRate); - - m_alacFile = AlacDecodeUtils.create_alac(bitsPerSample, 2); - m_alacFile.setinfo_max_samples_per_frame = m_samplesPerFrame; - m_alacFile.setinfo_7a = Integer.valueOf(formatOptions[FormatOption7a]); - m_alacFile.setinfo_sample_size = bitsPerSample; - m_alacFile.setinfo_rice_historymult = Integer.valueOf(formatOptions[FormatOptionRiceHistoryMult]); - m_alacFile.setinfo_rice_initialhistory = Integer.valueOf(formatOptions[FormatOptionRiceInitialHistory]); - m_alacFile.setinfo_rice_kmodifier = Integer.valueOf(formatOptions[FormatOptionRiceKModifier]); - m_alacFile.setinfo_7f = Integer.valueOf(formatOptions[FormatOption7f]); - m_alacFile.setinfo_80 = Integer.valueOf(formatOptions[FormatOption80]); - m_alacFile.setinfo_82 = Integer.valueOf(formatOptions[FormatOption82]); - m_alacFile.setinfo_86 = Integer.valueOf(formatOptions[FormatOption86]); - m_alacFile.setinfo_8a_rate = sampleRate; - - s_logger.info("Created ALAC decode for options " + Arrays.toString(formatOptions)); - } - - @Override - protected synchronized Object decode(final ChannelHandlerContext ctx, final Channel channel, final Object msg) - throws Exception - { - if (!(msg instanceof RaopRtpPacket.Audio)) - return msg; - - final RaopRtpPacket.Audio alacPacket = (RaopRtpPacket.Audio)msg; - - /* The ALAC decode sometimes reads beyond the input's bounds - * (but later discards the data). To alleviate, we allocate - * 3 spare bytes at input buffer's end. - */ - final byte[] alacBytes = new byte[alacPacket.getPayload().capacity() + 3]; - alacPacket.getPayload().getBytes(0, alacBytes, 0, alacPacket.getPayload().capacity()); - - /* Decode ALAC to PCM */ - final int[] pcmSamples = new int[m_samplesPerFrame * 2]; - final int pcmSamplesBytes = AlacDecodeUtils.decode_frame(m_alacFile, alacBytes, pcmSamples, m_samplesPerFrame); - - /* decode_frame() returns the number of *bytes*, not samples! */ - final int pcmSamplesLength = pcmSamplesBytes / 4; - final Level level = Level.FINEST; - if (s_logger.isLoggable(level)) - s_logger.log(level, "Decoded " + alacBytes.length + " bytes of ALAC audio data to " + pcmSamplesLength + " PCM samples"); - - /* Complain if the sender doesn't honour it's commitment */ - if (pcmSamplesLength != m_samplesPerFrame) - throw new ProtocolException("Frame declared to contain " + m_samplesPerFrame + ", but contained " + pcmSamplesLength); - - /* Assemble PCM audio packet from original packet header and decoded data. - * The ALAC decode emits signed PCM samples as integers. We store them as - * as unsigned big endian integers in the packet. - */ - - RaopRtpPacket.Audio pcmPacket; - if (alacPacket instanceof RaopRtpPacket.AudioTransmit) { - pcmPacket = new RaopRtpPacket.AudioTransmit(pcmSamplesLength * 4); - alacPacket.getBuffer().getBytes(0, pcmPacket.getBuffer(), 0, RaopRtpPacket.AudioTransmit.Length); - } - else if (alacPacket instanceof RaopRtpPacket.AudioRetransmit) { - pcmPacket = new RaopRtpPacket.AudioRetransmit(pcmSamplesLength * 4); - alacPacket.getBuffer().getBytes(0, pcmPacket.getBuffer(), 0, RaopRtpPacket.AudioRetransmit.Length); - } - else - throw new ProtocolException("Packet type " + alacPacket.getClass() + " is not supported by the ALAC decoder"); - - for(int i=0; i < pcmSamples.length; ++i) { - /* Convert sample to big endian unsigned integer PCM */ - final int pcmSampleUnsigned = pcmSamples[i] + 0x8000; - - pcmPacket.getPayload().setByte(2*i, (pcmSampleUnsigned & 0xff00) >> 8); - pcmPacket.getPayload().setByte(2*i + 1, pcmSampleUnsigned & 0x00ff); - } - - return pcmPacket; - } - - @Override - public AudioFormat getAudioFormat() { - return AudioOutputFormat; - } - - @Override - public int getFramesPerPacket() { - return m_samplesPerFrame; - } - - @Override - public double getPacketsPerSecond() { - return getAudioFormat().getSampleRate() / (double)getFramesPerPacket(); - } -} diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtpRetransmitRequestHandler.java b/src/main/java/org/phlo/AirReceiver/RaopRtpRetransmitRequestHandler.java index b0f0dda..2ca299c 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtpRetransmitRequestHandler.java +++ b/src/main/java/org/phlo/AirReceiver/RaopRtpRetransmitRequestHandler.java @@ -20,6 +20,9 @@ import java.util.*; import java.util.logging.Logger; +import nz.co.iswe.android.airplay.audio.AudioStreamInformationProvider; +import nz.co.iswe.android.airplay.network.raop.RaopRtpPacket; + import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.MessageEvent; diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtspChallengeResponseHandler.java b/src/main/java/org/phlo/AirReceiver/RaopRtspChallengeResponseHandler.java index 1cc47cd..900c311 100644 --- a/src/main/java/org/phlo/AirReceiver/RaopRtspChallengeResponseHandler.java +++ b/src/main/java/org/phlo/AirReceiver/RaopRtspChallengeResponseHandler.java @@ -17,13 +17,23 @@ package org.phlo.AirReceiver; -import java.net.*; +import java.net.InetAddress; +import java.net.InetSocketAddress; import java.nio.ByteBuffer; +import java.security.NoSuchAlgorithmException; +import java.util.logging.Level; +import java.util.logging.Logger; -import javax.crypto.*; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; -import org.jboss.netty.channel.*; -import org.jboss.netty.handler.codec.http.*; +import nz.co.iswe.android.airplay.crypto.AirTunesCryptography; + +import org.jboss.netty.channel.ChannelHandlerContext; +import org.jboss.netty.channel.MessageEvent; +import org.jboss.netty.channel.SimpleChannelHandler; +import org.jboss.netty.handler.codec.http.HttpRequest; +import org.jboss.netty.handler.codec.http.HttpResponse; /** * Adds an {@code Apple-Response} header to a response if the request contain @@ -34,16 +44,32 @@ public class RaopRtspChallengeResponseHandler extends SimpleChannelHandler private static final String HeaderChallenge = "Apple-Challenge"; private static final String HeaderSignature = "Apple-Response"; + private static final Logger LOG = Logger.getLogger(RaopRtspChallengeResponseHandler.class.getName()); + + private final byte[] m_hwAddress; - private final Cipher m_rsaPkCS1PaddingCipher = AirTunesCrytography.getCipher("RSA/None/PKCS1Padding"); - + //private final Cipher m_rsaPkCS1PaddingCipher = AirTunesCrytography.getCipher("RSA/None/PKCS1Padding"); + private Cipher rsaPkCS1PaddingCipher; + private byte[] m_challenge; private InetAddress m_localAddress; public RaopRtspChallengeResponseHandler(final byte[] hwAddress) { assert hwAddress.length == 6; - m_hwAddress = hwAddress; + + String transformation = "RSA/None/PKCS1Padding"; + try { + rsaPkCS1PaddingCipher = Cipher.getInstance(transformation); + + LOG.info("Cipher acquired sucessfully. transformation: " + transformation); + } + catch (NoSuchAlgorithmException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } + catch (NoSuchPaddingException e) { + LOG.log(Level.SEVERE, "Error getting the Cipher. transformation: " + transformation, e); + } } @Override @@ -114,8 +140,8 @@ private byte[] getSignature() { sigData.put((byte)0); try { - m_rsaPkCS1PaddingCipher.init(Cipher.ENCRYPT_MODE, AirTunesCrytography.PrivateKey); - return m_rsaPkCS1PaddingCipher.doFinal(sigData.array()); + rsaPkCS1PaddingCipher.init(Cipher.ENCRYPT_MODE, AirTunesCryptography.PrivateKey); + return rsaPkCS1PaddingCipher.doFinal(sigData.array()); } catch (final Exception e) { throw new RuntimeException("Unable to sign response", e); diff --git a/src/main/java/org/phlo/AirReceiver/RaopRtspPipelineFactory.java b/src/main/java/org/phlo/AirReceiver/RaopRtspPipelineFactory.java deleted file mode 100644 index 146934c..0000000 --- a/src/main/java/org/phlo/AirReceiver/RaopRtspPipelineFactory.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * This file is part of AirReceiver. - * - * AirReceiver is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - - * AirReceiver is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - - * You should have received a copy of the GNU General Public License - * along with AirReceiver. If not, see . - */ - -package org.phlo.AirReceiver; - -import org.jboss.netty.channel.*; -import org.jboss.netty.handler.codec.rtsp.*; - -/** - * Factory for AirTunes/RAOP RTSP channels - */ -public class RaopRtspPipelineFactory implements ChannelPipelineFactory { - @Override - public ChannelPipeline getPipeline() throws Exception { - final ChannelPipeline pipeline = Channels.pipeline(); - - pipeline.addLast("executionHandler", AirReceiver.ChannelExecutionHandler); - pipeline.addLast("closeOnShutdownHandler", AirReceiver.CloseChannelOnShutdownHandler); - pipeline.addLast("exceptionLogger", new ExceptionLoggingHandler()); - pipeline.addLast("decoder", new RtspRequestDecoder()); - pipeline.addLast("encoder", new RtspResponseEncoder()); - pipeline.addLast("logger", new RtspLoggingHandler()); - pipeline.addLast("errorResponse", new RtspErrorResponseHandler()); - pipeline.addLast("challengeResponse", new RaopRtspChallengeResponseHandler(AirReceiver.HardwareAddressBytes)); - pipeline.addLast("header", new RaopRtspHeaderHandler()); - pipeline.addLast("options", new RaopRtspOptionsHandler()); - pipeline.addLast("audio", new RaopAudioHandler(AirReceiver.ExecutorService)); - pipeline.addLast("unsupportedResponse", new RtspUnsupportedResponseHandler()); - - return pipeline; - } - -} diff --git a/src/main/java/org/phlo/AirReceiver/RunningExponentialAverage.java b/src/main/java/org/phlo/AirReceiver/RunningExponentialAverage.java index db9c4ea..9825cb8 100644 --- a/src/main/java/org/phlo/AirReceiver/RunningExponentialAverage.java +++ b/src/main/java/org/phlo/AirReceiver/RunningExponentialAverage.java @@ -22,20 +22,20 @@ * over a series of values. */ public class RunningExponentialAverage { - public double m_value = Double.NaN; + public double value = Double.NaN; /** * Create an exponential average without an initial value */ public RunningExponentialAverage() { - m_value = Double.NaN; + value = Double.NaN; } /** * Create an exponential average with the given initial value */ public RunningExponentialAverage(final double initialValue) { - m_value = initialValue; + value = initialValue; } /** @@ -45,11 +45,11 @@ public RunningExponentialAverage(final double initialValue) { * @param weight the value's weight between 0 and 1. */ public void add(final double value, final double weight) { - if (Double.isNaN(m_value)) { - m_value = value; + if (Double.isNaN(this.value)) { + this.value = value; } else { - m_value = value * weight + m_value * (1.0 - weight); + this.value = value * weight + this.value * (1.0 - weight); } } @@ -59,7 +59,7 @@ public void add(final double value, final double weight) { * Otherwise, always returns false. */ public boolean isEmpty() { - return Double.isNaN(m_value); + return Double.isNaN(value); } /** @@ -67,6 +67,6 @@ public boolean isEmpty() { * @return exponential average */ public double get() { - return m_value; + return value; } } diff --git a/src/main/resources/logging.properties b/src/main/resources/logging.properties index ae46baf..3ed978b 100644 --- a/src/main/resources/logging.properties +++ b/src/main/resources/logging.properties @@ -4,6 +4,7 @@ java.util.logging.ConsoleHandler.level=ALL .level=WARNING org.phlo.AirReceiver.level=INFO +nz.co.iswe.android.airplay.level=INFO #javax.jmdns.level=ALL #org.phlo.AirReceiver.RaopRtpTimingHandler.level=FINEST #org.phlo.AirReceiver.AudioOutputQueue.level=FINE diff --git a/src/test/java/nz/co/iswe/android/airplay/audio/RaopAudioHandlerTest.java b/src/test/java/nz/co/iswe/android/airplay/audio/RaopAudioHandlerTest.java new file mode 100644 index 0000000..9d2f59c --- /dev/null +++ b/src/test/java/nz/co/iswe/android/airplay/audio/RaopAudioHandlerTest.java @@ -0,0 +1,33 @@ +package nz.co.iswe.android.airplay.audio; + +import java.util.regex.Matcher; + +import junit.framework.Assert; + +import nz.co.iswe.android.airplay.audio.RaopAudioHandler; + +import org.junit.Test; + + +public class RaopAudioHandlerTest { + + @Test + public void testPatternSdpLine(){ + + String line = "rtpmap:value"; + Matcher matcher = RaopAudioHandler.s_pattern_sdp_a.matcher(line); + Assert.assertTrue(matcher.matches()); + + + line = "min-latency:11400"; + matcher = RaopAudioHandler.s_pattern_sdp_a.matcher(line); + Assert.assertTrue(matcher.matches()); + + //line = "min:latency:11400"; + //matcher = RaopAudioHandler.s_pattern_sdp_a.matcher(line); + //Assert.assertFalse(matcher.matches()); + + } + + +}