blob: 43ae23729e7420116334260091b2f811076d0dac [file] [log] [blame]
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
using System;
using System.IO;
using System.IO.IsolatedStorage;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Media;
using Microsoft.Phone.Controls;
using System.Diagnostics;
namespace WP7CordovaClassLib.Cordova.Commands
{
/// <summary>
/// Implements audio record and play back functionality.
/// </summary>
internal class AudioPlayer : IDisposable
{
#region Constants
// AudioPlayer states
private const int MediaNone = 0;
private const int MediaStarting = 1;
private const int MediaRunning = 2;
private const int MediaPaused = 3;
private const int MediaStopped = 4;
// AudioPlayer messages
private const int MediaState = 1;
private const int MediaDuration = 2;
private const int MediaPosition = 3;
private const int MediaError = 9;
// AudioPlayer errors
private const int MediaErrorPlayModeSet = 1;
private const int MediaErrorAlreadyRecording = 2;
private const int MediaErrorStartingRecording = 3;
private const int MediaErrorRecordModeSet = 4;
private const int MediaErrorStartingPlayback = 5;
private const int MediaErrorResumeState = 6;
private const int MediaErrorPauseState = 7;
private const int MediaErrorStopState = 8;
//TODO: get rid of this callback, it should be universal
private const string CallbackFunction = "CordovaMediaonStatus";
#endregion
/// <summary>
/// The AudioHandler object
/// </summary>
private Media handler;
/// <summary>
/// Temporary buffer to store audio chunk
/// </summary>
private byte[] buffer;
/// <summary>
/// Xna game loop dispatcher
/// </summary>
DispatcherTimer dtXna;
/// <summary>
/// Output buffer
/// </summary>
private MemoryStream memoryStream;
/// <summary>
/// The id of this player (used to identify Media object in JavaScript)
/// </summary>
private String id;
/// <summary>
/// State of recording or playback
/// </summary>
private int state = MediaNone;
/// <summary>
/// File name to play or record to
/// </summary>
private String audioFile = null;
/// <summary>
/// Duration of audio
/// </summary>
private double duration = -1;
/// <summary>
/// Audio player object
/// </summary>
private MediaElement player = null;
/// <summary>
/// Audio source
/// </summary>
private Microphone recorder;
/// <summary>
/// Internal flag specified that we should only open audio w/o playing it
/// </summary>
private bool prepareOnly = false;
/// <summary>
/// Creates AudioPlayer instance
/// </summary>
/// <param name="handler">Media object</param>
/// <param name="id">player id</param>
public AudioPlayer(Media handler, String id)
{
this.handler = handler;
this.id = id;
}
/// <summary>
/// Destroys player and stop audio playing or recording
/// </summary>
public void Dispose()
{
Debug.WriteLine("Dispose :: " + this.audioFile);
if (this.player != null)
{
this.stopPlaying();
this.player = null;
}
if (this.recorder != null)
{
this.stopRecording();
this.recorder = null;
}
this.FinalizeXnaGameLoop();
}
/// <summary>
/// Starts recording, data is stored in memory
/// </summary>
/// <param name="filePath"></param>
public void startRecording(string filePath)
{
if (this.player != null)
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorPlayModeSet));
}
else if (this.recorder == null)
{
try
{
this.audioFile = filePath;
this.InitializeXnaGameLoop();
this.recorder = Microphone.Default;
this.recorder.BufferDuration = TimeSpan.FromMilliseconds(500);
this.buffer = new byte[recorder.GetSampleSizeInBytes(this.recorder.BufferDuration)];
this.recorder.BufferReady += new EventHandler<EventArgs>(recorderBufferReady);
this.memoryStream = new MemoryStream();
this.WriteWavHeader(this.memoryStream, this.recorder.SampleRate);
this.recorder.Start();
FrameworkDispatcher.Update();
this.SetState(MediaRunning);
}
catch (Exception)
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorStartingRecording));
}
}
else
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorAlreadyRecording));
}
}
/// <summary>
/// Stops recording
/// </summary>
public void stopRecording()
{
if (this.recorder != null)
{
if (this.state == MediaRunning)
{
try
{
this.recorder.Stop();
this.recorder.BufferReady -= recorderBufferReady;
this.recorder = null;
SaveAudioClipToLocalStorage();
this.FinalizeXnaGameLoop();
this.SetState(MediaStopped);
}
catch (Exception)
{
//TODO
}
}
}
}
/// <summary>
/// Starts or resume playing audio file
/// </summary>
/// <param name="filePath">The name of the audio file</param>
/// <summary>
/// Starts or resume playing audio file
/// </summary>
/// <param name="filePath">The name of the audio file</param>
public void startPlaying(string filePath)
{
if (this.recorder != null)
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorRecordModeSet));
return;
}
if (this.player == null || this.player.Source.AbsolutePath.LastIndexOf(filePath) < 0)
{
try
{
// this.player is a MediaElement, it must be added to the visual tree in order to play
PhoneApplicationFrame frame = Application.Current.RootVisual as PhoneApplicationFrame;
if (frame != null)
{
PhoneApplicationPage page = frame.Content as PhoneApplicationPage;
if (page != null)
{
Grid grid = page.FindName("LayoutRoot") as Grid;
if (grid != null)
{
//Microsoft.Xna.Framework.Media.MediaPlayer.Play(
this.player = grid.FindName("playerMediaElement") as MediaElement;
if (this.player == null) // still null ?
{
this.player = new MediaElement();
this.player.Name = "playerMediaElement";
grid.Children.Add(this.player);
this.player.Visibility = Visibility.Visible;
}
if (this.player.CurrentState == System.Windows.Media.MediaElementState.Playing)
{
this.player.Stop(); // stop it!
}
this.player.Source = null; // Garbage collect it.
this.player.MediaOpened += MediaOpened;
this.player.MediaEnded += MediaEnded;
this.player.MediaFailed += MediaFailed;
}
}
}
this.audioFile = filePath;
Uri uri = new Uri(filePath, UriKind.RelativeOrAbsolute);
if (uri.IsAbsoluteUri)
{
this.player.Source = uri;
}
else
{
using (IsolatedStorageFile isoFile = IsolatedStorageFile.GetUserStoreForApplication())
{
if (isoFile.FileExists(filePath))
{
using (IsolatedStorageFileStream stream = new IsolatedStorageFileStream(filePath, FileMode.Open, isoFile))
{
this.player.SetSource(stream);
}
}
else
{
Debug.WriteLine("Error: source doesn't exist :: " + filePath);
throw new ArgumentException("Source doesn't exist");
}
}
}
this.SetState(MediaStarting);
}
catch (Exception e)
{
Debug.WriteLine("Error: " + e.Message);
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorStartingPlayback));
}
}
else
{
if (this.state != MediaRunning)
{
this.player.Play();
this.SetState(MediaRunning);
}
else
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorResumeState));
}
}
}
/// <summary>
/// Callback to be invoked when the media source is ready for playback
/// </summary>
private void MediaOpened(object sender, RoutedEventArgs arg)
{
if (!this.prepareOnly)
{
this.player.Play();
this.SetState(MediaRunning);
}
this.duration = this.player.NaturalDuration.TimeSpan.TotalSeconds;
this.prepareOnly = false;
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaDuration, this.duration));
}
/// <summary>
/// Callback to be invoked when playback of a media source has completed
/// </summary>
private void MediaEnded(object sender, RoutedEventArgs arg)
{
this.SetState(MediaStopped);
}
/// <summary>
/// Callback to be invoked when playback of a media source has failed
/// </summary>
private void MediaFailed(object sender, RoutedEventArgs arg)
{
player.Stop();
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError.ToString(), "Media failed"));
}
/// <summary>
/// Seek or jump to a new time in the track
/// </summary>
/// <param name="milliseconds">The new track position</param>
public void seekToPlaying(int milliseconds)
{
if (this.player != null)
{
TimeSpan tsPos = new TimeSpan(0, 0, 0, 0, milliseconds);
this.player.Position = tsPos;
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaPosition, milliseconds / 1000.0f));
}
}
/// <summary>
/// Set the volume of the player
/// </summary>
/// <param name="vol">volume 0.0-1.0, default value is 0.5</param>
public void setVolume(double vol)
{
if (this.player != null)
{
this.player.Volume = vol;
}
}
/// <summary>
/// Pauses playing
/// </summary>
public void pausePlaying()
{
if (this.state == MediaRunning)
{
this.player.Pause();
this.SetState(MediaPaused);
}
else
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorPauseState));
}
}
/// <summary>
/// Stops playing the audio file
/// </summary>
public void stopPlaying()
{
if ((this.state == MediaRunning) || (this.state == MediaPaused))
{
this.player.Stop();
this.player.Position = new TimeSpan(0L);
this.SetState(MediaStopped);
}
else
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaError, MediaErrorStopState));
}
}
/// <summary>
/// Gets current position of playback
/// </summary>
/// <returns>current position</returns>
public double getCurrentPosition()
{
if ((this.state == MediaRunning) || (this.state == MediaPaused))
{
double currentPosition = this.player.Position.TotalSeconds;
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaPosition, currentPosition));
return currentPosition;
}
else
{
return -1;
}
}
/// <summary>
/// Gets the duration of the audio file
/// </summary>
/// <param name="filePath">The name of the audio file</param>
/// <returns>track duration</returns>
public double getDuration(string filePath)
{
if (this.recorder != null)
{
return (-2);
}
if (this.player != null)
{
return this.duration;
}
else
{
this.prepareOnly = true;
this.startPlaying(filePath);
return this.duration;
}
}
/// <summary>
/// Sets the state and send it to JavaScript
/// </summary>
/// <param name="state">state</param>
private void SetState(int state)
{
if (this.state != state)
{
this.handler.InvokeCustomScript(new ScriptCallback(CallbackFunction, this.id, MediaState, state));
}
this.state = state;
}
#region record methods
/// <summary>
/// Copies data from recorder to memory storages and updates recording state
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void recorderBufferReady(object sender, EventArgs e)
{
this.recorder.GetData(this.buffer);
this.memoryStream.Write(this.buffer, 0, this.buffer.Length);
}
/// <summary>
/// Writes audio data from memory to isolated storage
/// </summary>
/// <returns></returns>
private void SaveAudioClipToLocalStorage()
{
if (this.memoryStream == null || this.memoryStream.Length <= 0)
{
return;
}
this.UpdateWavHeader(this.memoryStream);
try
{
using (IsolatedStorageFile isoFile = IsolatedStorageFile.GetUserStoreForApplication())
{
string directory = Path.GetDirectoryName(audioFile);
if (!isoFile.DirectoryExists(directory))
{
isoFile.CreateDirectory(directory);
}
this.memoryStream.Seek(0, SeekOrigin.Begin);
using (IsolatedStorageFileStream fileStream = isoFile.CreateFile(audioFile))
{
this.memoryStream.CopyTo(fileStream);
}
}
}
catch (Exception)
{
//TODO: log or do something else
throw;
}
}
#region Wav format
// Original source http://damianblog.com/2011/02/07/storing-wp7-recorded-audio-as-wav-format-streams/
/// <summary>
/// Adds wav file format header to the stream
/// https://ccrma.stanford.edu/courses/422/projects/WaveFormat/
/// </summary>
/// <param name="stream">Wav stream</param>
/// <param name="sampleRate">Sample Rate</param>
private void WriteWavHeader(Stream stream, int sampleRate)
{
const int bitsPerSample = 16;
const int bytesPerSample = bitsPerSample / 8;
var encoding = System.Text.Encoding.UTF8;
// ChunkID Contains the letters "RIFF" in ASCII form (0x52494646 big-endian form).
stream.Write(encoding.GetBytes("RIFF"), 0, 4);
// NOTE this will be filled in later
stream.Write(BitConverter.GetBytes(0), 0, 4);
// Format Contains the letters "WAVE"(0x57415645 big-endian form).
stream.Write(encoding.GetBytes("WAVE"), 0, 4);
// Subchunk1ID Contains the letters "fmt " (0x666d7420 big-endian form).
stream.Write(encoding.GetBytes("fmt "), 0, 4);
// Subchunk1Size 16 for PCM. This is the size of therest of the Subchunk which follows this number.
stream.Write(BitConverter.GetBytes(16), 0, 4);
// AudioFormat PCM = 1 (i.e. Linear quantization) Values other than 1 indicate some form of compression.
stream.Write(BitConverter.GetBytes((short)1), 0, 2);
// NumChannels Mono = 1, Stereo = 2, etc.
stream.Write(BitConverter.GetBytes((short)1), 0, 2);
// SampleRate 8000, 44100, etc.
stream.Write(BitConverter.GetBytes(sampleRate), 0, 4);
// ByteRate = SampleRate * NumChannels * BitsPerSample/8
stream.Write(BitConverter.GetBytes(sampleRate * bytesPerSample), 0, 4);
// BlockAlign NumChannels * BitsPerSample/8 The number of bytes for one sample including all channels.
stream.Write(BitConverter.GetBytes((short)(bytesPerSample)), 0, 2);
// BitsPerSample 8 bits = 8, 16 bits = 16, etc.
stream.Write(BitConverter.GetBytes((short)(bitsPerSample)), 0, 2);
// Subchunk2ID Contains the letters "data" (0x64617461 big-endian form).
stream.Write(encoding.GetBytes("data"), 0, 4);
// NOTE to be filled in later
stream.Write(BitConverter.GetBytes(0), 0, 4);
}
/// <summary>
/// Updates wav file format header
/// https://ccrma.stanford.edu/courses/422/projects/WaveFormat/
/// </summary>
/// <param name="stream">Wav stream</param>
private void UpdateWavHeader(Stream stream)
{
if (!stream.CanSeek) throw new Exception("Can't seek stream to update wav header");
var oldPos = stream.Position;
// ChunkSize 36 + SubChunk2Size
stream.Seek(4, SeekOrigin.Begin);
stream.Write(BitConverter.GetBytes((int)stream.Length - 8), 0, 4);
// Subchunk2Size == NumSamples * NumChannels * BitsPerSample/8 This is the number of bytes in the data.
stream.Seek(40, SeekOrigin.Begin);
stream.Write(BitConverter.GetBytes((int)stream.Length - 44), 0, 4);
stream.Seek(oldPos, SeekOrigin.Begin);
}
#endregion
#region Xna loop
/// <summary>
/// Special initialization required for the microphone: XNA game loop
/// </summary>
private void InitializeXnaGameLoop()
{
// Timer to simulate the XNA game loop (Microphone is from XNA)
this.dtXna = new DispatcherTimer();
this.dtXna.Interval = TimeSpan.FromMilliseconds(33);
this.dtXna.Tick += delegate { try { FrameworkDispatcher.Update(); } catch { } };
this.dtXna.Start();
}
/// <summary>
/// Finalizes XNA game loop for microphone
/// </summary>
private void FinalizeXnaGameLoop()
{
// Timer to simulate the XNA game loop (Microphone is from XNA)
this.dtXna.Stop();
this.dtXna = null;
}
#endregion
#endregion
}
}