diff --git a/BizHawk.MultiClient/AviWriter.cs b/BizHawk.MultiClient/AviWriter.cs index 7f406bcf21..17a623d6be 100644 --- a/BizHawk.MultiClient/AviWriter.cs +++ b/BizHawk.MultiClient/AviWriter.cs @@ -350,6 +350,14 @@ namespace BizHawk.MultiClient } } + /// + /// set metadata parameters; should be called before opening file + /// NYI + /// + public void SetMetaData(string gameName, string authors, UInt64 lengthMS, UInt64 rerecords) + { + } + unsafe class AviWriterSegment : IDisposable { static AviWriterSegment() diff --git a/BizHawk.MultiClient/BizHawk.MultiClient.csproj b/BizHawk.MultiClient/BizHawk.MultiClient.csproj index 5f336bb03e..58c2bcbbbb 100644 --- a/BizHawk.MultiClient/BizHawk.MultiClient.csproj +++ b/BizHawk.MultiClient/BizHawk.MultiClient.csproj @@ -82,6 +82,7 @@ 3.5 + 3.5 @@ -174,6 +175,7 @@ + Form diff --git a/BizHawk.MultiClient/IVideoWriter.cs b/BizHawk.MultiClient/IVideoWriter.cs index ace087e2bd..c6317a77d3 100644 --- a/BizHawk.MultiClient/IVideoWriter.cs +++ b/BizHawk.MultiClient/IVideoWriter.cs @@ -33,6 +33,7 @@ namespace BizHawk /// /// adds audio samples to the stream /// no attempt is made to sync this to the video + /// reccomendation: try not to have the size or pacing of the audio chunks be too "weird" /// void AddSamples(short[] samples); @@ -63,5 +64,15 @@ namespace BizHawk /// void SetAudioParameters(int sampleRate, int channels, int bits); + /// + /// set metadata parameters; should be called before opening file + /// ok to not set at all, if not applicable + /// + /// The name of the game loaded + /// Authors on movie file + /// Length of movie file in milliseconds + /// Number of rerecords on movie file + void SetMetaData(string gameName, string authors, UInt64 lengthMS, UInt64 rerecords); + } } diff --git a/BizHawk.MultiClient/JMDWriter.cs b/BizHawk.MultiClient/JMDWriter.cs new file mode 100644 index 0000000000..51e6722636 --- /dev/null +++ b/BizHawk.MultiClient/JMDWriter.cs @@ -0,0 +1,658 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text; +using ICSharpCode.SharpZipLib.Zip.Compression.Streams; +using ICSharpCode.SharpZipLib.Zip.Compression; + +namespace BizHawk.MultiClient +{ + class JMDWriter : IVideoWriter + { + /// + /// carries private compression information data + /// NYI + /// + class CodecToken : IDisposable + { + public void Dispose() + { + } + + // get constants from Deflater + public int compressionlevel + { + get; + private set; + } + + public int numthreads + { + get; + private set; + } + + public CodecToken() + { + compressionlevel = Deflater.DEFAULT_COMPRESSION; + numthreads = 3; + } + } + + CodecToken token; + /// + /// fps numerator, constant + /// + int fpsnum; + /// + /// fps denominator, constant + /// + int fpsden; + + /// + /// audio samplerate, constant + /// + int audiosamplerate; + /// + /// audio number of channels, constant; 1 or 2 only + /// + int audiochannels; + /// + /// audio bits per sample, constant; only 16 supported + /// + int audiobits; + + /// + /// actual disk file being written + /// + FileStream JMDfile; + + /// + /// current timestamp offset in JMD + /// ie, (number of ffffffffff appearances) * (ffffffff) + /// + UInt64 timestampoff; + /// + /// total number of video frames, used to calculate timestamps + /// + UInt64 totalframes; + + /// + /// total number of audio samples, used to calculate timestamps + /// + UInt64 totalsamples; + + // movie metadata + + string gamename; + string authors; + UInt64 lengthms; + UInt64 rerecords; + + + + /// + /// sets default (probably wrong) parameters + /// + public JMDWriter() + { + fpsnum = 25; + fpsden = 1; + audiosamplerate = 22050; + audiochannels = 1; + audiobits = 8; + token = null; + + gamename = ""; + authors = ""; + lengthms = 0; + rerecords = 0; + } + + public void Dispose() + { + } + + + /// + /// sets the codec token to be used for video compression + /// + public void SetVideoCodecToken(IDisposable token) + { + if (token is CodecToken) + this.token = (CodecToken)token; + else + throw new ArgumentException("codec token must be of right type"); + } + + /// + /// obtain a set of recording compression parameters + /// + /// hwnd to attach to if the user is shown config dialog + /// codec token, dispose of it when you're done with it + public IDisposable AcquireVideoCodecToken(IntPtr hwnd) + { + // no user interaction for now + return new CodecToken(); + } + + /// + /// set framerate to fpsnum/fpsden (assumed to be unchanging over the life of the stream) + /// + public void SetMovieParameters(int fpsnum, int fpsden) + { + this.fpsnum = fpsnum; + this.fpsden = fpsden; + } + + /// + /// set resolution parameters (width x height) + /// must be set before file is opened + /// can be changed in future + /// should always match IVideoProvider + /// + /// + /// + public void SetVideoParameters(int width, int height) + { + // each frame is dumped with its resolution, so we don't care to store this or monitor it + } + + /// + /// set audio parameters. cannot change later + /// + public void SetAudioParameters(int sampleRate, int channels, int bits) + { + // these are pretty arbitrary + if (sampleRate < 8000 || sampleRate > 96000 || channels < 1 || channels > 2 || bits != 16) + throw new ArgumentException("Audio parameters out of range!"); + audiosamplerate = sampleRate; + audiochannels = channels; + audiobits = bits; + } + + + /// + /// opens a recording stream + /// set a video codec token first. + /// + public void OpenFile(string baseName) + { + string ext = Path.GetExtension(baseName); + if (ext == null || ext.ToLower() != "jmd") + baseName = baseName + ".jmd"; + + JMDfile = File.Open(baseName, FileMode.OpenOrCreate); + timestampoff = 0; + totalframes = 0; + totalsamples = 0; + + // write JPC MAGIC + writeBE16(0xffff); + JMDfile.Write(Encoding.ASCII.GetBytes("JPCRRMULTIDUMP"), 0, 14); + + // write channel table + writeBE16(3); // number of streams + + // for each stream + writeBE16(0); // channel 0 + writeBE16(0); // video + writeBE16(0); // no name + + writeBE16(1); // channel 1 + writeBE16(1); // pcm audio + writeBE16(0); // no name + + writeBE16(2); // channel 2 + writeBE16(5); // metadata + writeBE16(0); // no name + + if (gamename != null && gamename != String.Empty) + { + byte[] temp; + // write metadatas + writeBE16(2); // data channel + writeBE32(0); // timestamp 0; + JMDfile.WriteByte(71); // gamename + temp = System.Text.Encoding.UTF8.GetBytes(gamename); + writeVar(temp.Length); + JMDfile.Write(temp, 0, temp.Length); + + writeBE16(2); + writeBE32(0); + JMDfile.WriteByte(65); // authors + temp = System.Text.Encoding.UTF8.GetBytes(authors); + writeVar(temp.Length); + JMDfile.Write(temp, 0, temp.Length); + + writeBE16(2); + writeBE32(0); + JMDfile.WriteByte(76); // length + writeVar(8); + writeBE64(lengthms * 1000000); + + writeBE16(2); + writeBE32(0); + JMDfile.WriteByte(82); // rerecords + writeVar(8); + writeBE64(rerecords); + } + + // start up thread + // problem: since audio chunks and video frames both go through here, exactly how many worker gzips this + // gives is not known without knowing how the emulator will chunk audio packets + // this shouldn't affect results though, just performance + threadQ = new System.Collections.Concurrent.BlockingCollection(token.numthreads * 2); + workerT = new System.Threading.Thread(new System.Threading.ThreadStart(threadproc)); + workerT.Start(); + GzipFrameDelegate = new GzipFrameD(GzipFrame); + astorage = new Queue(); + vstorage = new Queue(); + } + + // some of this code is copied from AviWriter... not sure how if at all it should be abstracted + /// + /// blocking threadsafe queue, used for communication between main program and file writing thread + /// + System.Collections.Concurrent.BlockingCollection threadQ; + /// + /// file writing thread; most of the work happens here + /// + System.Threading.Thread workerT; + + /// + /// filewriting thread's loop + /// + void threadproc() + { + try + { + while (true) + { + Object o = threadQ.Take(); + if (o is IAsyncResult) + AddFrameEx(GzipFrameDelegate.EndInvoke((IAsyncResult)o)); + else if (o is short[]) + AddSamplesEx((short[])o); + else + // anything else is assumed to be quit time + return; + } + } + catch (Exception e) + { + System.Windows.Forms.MessageBox.Show("JMD Worker Thread died:\n\n" + e.ToString()); + return; + + } + } + + /// + /// write big endian 16 bit unsigned to JMDfile + /// + /// + void writeBE16(UInt16 v) + { + byte[] b = new byte[2]; + b[0] = (byte)(v >> 8); + b[1] = (byte)(v & 255); + JMDfile.Write(b, 0, 2); + } + + /// + /// write big endian 32 bit unsigned to JMDfile + /// + /// + void writeBE32(UInt32 v) + { + byte[] b = new byte[4]; + b[0] = (byte)(v >> 24); + b[1] = (byte)(v >> 16); + b[2] = (byte)(v >> 8); + b[3] = (byte)(v & 255); + JMDfile.Write(b, 0, 4); + } + + /// + /// write big endian 64 bit unsigned to JMDfile + /// + /// + void writeBE64(UInt64 v) + { + byte[] b = new byte[8]; + for (int i = 7; i >= 0; i--) + { + b[i] = (byte)(v & 255); + v >>= 8; + } + JMDfile.Write(b, 0, 8); + } + + /// + /// write variable length number to file + /// encoding is similar to MIDI + /// + /// + void writeVar(UInt64 v) + { + byte[] b = new byte[10]; + int i = 0; + while (v > 0) + { + if (i > 0) + b[i++] = (byte)((v & 127) | 128); + else + b[i++] = (byte)(v & 127); + v /= 128; + } + if (i == 0) + JMDfile.WriteByte(0); + else + for (; i > 0; i--) + JMDfile.WriteByte(b[i - 1]); + } + + /// + /// write variable length number to file + /// encoding is similar to MIDI + /// + /// + void writeVar(int v) + { + if (v < 0) + throw new ArgumentException("length cannot be less than 0!"); + writeVar((UInt64)v); + } + + /// + /// write packet, but they have to be in order! + /// + /// + void writeActual(JMDPacket j) + { + if (j.timestamp < timestampoff) + throw new ArithmeticException("JMD Timestamp problem?"); + UInt64 timestampout = j.timestamp - timestampoff; + while (timestampout > 0xffffffff) + { + timestampout -= 0xffffffff; + // write timestamp skipper + for (int i = 0; i < 6; i++) + JMDfile.WriteByte(0xff); + } + timestampoff = j.timestamp; + writeBE16(j.stream); + writeBE32((UInt32)timestampout); + JMDfile.WriteByte(j.subtype); + writeVar((UInt64)j.data.LongLength); + JMDfile.Write(j.data, 0, j.data.Length); + } + + + // ensure outputs are in order + // JMD packets must be in nondecreasing timestamp order, but there's no obligation + // for us to get handed that. this code is a bit overcomplex to handle edge cases + // that may not be a problem with the current system? + + /// + /// collection of JMDpackets yet to be written (audio) + /// assumed to be in order + /// + Queue astorage; + /// + /// collection of JMDpackets yet to be written (video) + /// assumed to be in order + /// + Queue vstorage; + + /// + /// add a sound packet to the file write queue + /// will be written when order-appropriate wrt video + /// + /// + void writesound(JMDPacket j) + { + while (vstorage.Count > 0) + { + var p = vstorage.Peek(); + if (p.timestamp <= j.timestamp) + writeActual(vstorage.Dequeue()); + else + break; + } + astorage.Enqueue(j); + } + + /// + /// add a video packet to the file write queue + /// will be written when order-appropriate wrt audio + /// + /// + void writevideo(JMDPacket j) + { + while (astorage.Count > 0) + { + var p = astorage.Peek(); + if (p.timestamp <= j.timestamp) + writeActual(astorage.Dequeue()); + else + break; + } + vstorage.Enqueue(j); + } + /// + /// flush all remaining JMDPackets to file + /// call before closing the file + /// + void flushpackets() + { + while (astorage.Count > 0 && vstorage.Count > 0) + { + var ap = astorage.Peek(); + var av = vstorage.Peek(); + if (ap.timestamp <= av.timestamp) + writeActual(astorage.Dequeue()); + else + writeActual(vstorage.Dequeue()); + } + while (astorage.Count > 0) + writeActual(astorage.Dequeue()); + while (vstorage.Count > 0) + writeActual(vstorage.Dequeue()); + } + + /// + /// close recording stream + /// + public void CloseFile() + { + threadQ.Add(new Object()); // acts as stop message + workerT.Join(); + + flushpackets(); + + JMDfile.Close(); + } + + /// + /// makes a copy of an IVideoProvider + /// handles conversion to a byte array suitable for compression by zlib + /// + class VideoCopy + { + public byte[] VideoBuffer; + + public int BufferWidth; + public int BufferHeight; + public int BackgroundColor; + public VideoCopy(IVideoProvider c) + { + int[] vb = c.GetVideoBuffer(); + VideoBuffer = new byte[vb.Length * sizeof(int)]; + // we have to switch RGB ordering here + for (int i = 0; i < vb.Length; i++) + { + VideoBuffer[i * 4 + 0] = (byte)(vb[i] >> 16); + VideoBuffer[i * 4 + 1] = (byte)(vb[i] >> 8); + VideoBuffer[i * 4 + 2] = (byte)(vb[i] & 255); + VideoBuffer[i * 4 + 3] = 0; + } + //Buffer.BlockCopy(vb, 0, VideoBuffer, 0, VideoBuffer.Length); + BufferWidth = c.BufferWidth; + BufferHeight = c.BufferHeight; + BackgroundColor = c.BackgroundColor; + } + } + + /// + /// deflates (zlib) a VideoCopy, returning a byte array suitable for insertion into a JMD file + /// the byte array includes width and height dimensions at the beginning + /// this is run asynchronously for speedup, as compressing can be slow + /// + /// + /// + byte[] GzipFrame(VideoCopy v) + { + MemoryStream m = new MemoryStream(); + // write frame height and width first + m.WriteByte((byte)(v.BufferWidth >> 8)); + m.WriteByte((byte)(v.BufferWidth & 255)); + m.WriteByte((byte)(v.BufferHeight >> 8)); + m.WriteByte((byte)(v.BufferHeight & 255)); + // NET 4.5 is needed for CompressionLevel? what a pile of balls + //var g = new DeflateStream(m, CompressionMode.Compress, true); + var g = new DeflaterOutputStream(m, new ICSharpCode.SharpZipLib.Zip.Compression.Deflater(token.compressionlevel)); + g.IsStreamOwner = false; // leave memory stream open so we can pick its contents + g.Write(v.VideoBuffer, 0, v.VideoBuffer.Length); + g.Flush(); + g.Close(); + byte[] ret = m.GetBuffer(); + Array.Resize(ref ret, (int)m.Length); + m.Close(); + return ret; + } + + /// + /// delegate for GzipFrame + /// + /// VideoCopy to compress + /// gzipped stream with width and height prepended + delegate byte[] GzipFrameD(VideoCopy v); + GzipFrameD GzipFrameDelegate; + + /// + /// adds a frame to the stream + /// + public void AddFrame(IVideoProvider source) + { + if (!workerT.IsAlive) + // signal some sort of error? + return; + threadQ.Add(GzipFrameDelegate.BeginInvoke(new VideoCopy(source), null, null)); + } + + /// + /// adds audio samples to the stream + /// no attempt is made to sync this to the video + /// + public void AddSamples(short[] samples) + { + if (!workerT.IsAlive) + // signal some sort of error? + return; + threadQ.Add((short[])samples.Clone()); + } + + /// + /// assemble JMDPacket and send to packetqueue + /// + /// + void AddFrameEx(byte[] source) + { + // at this point, VideoCopy contains a gzipped bytestream + var j = new JMDPacket(); + j.stream = 0; + j.subtype = 1; // zlib compressed + j.data = source; + j.timestamp = timestampcalc(fpsnum, fpsden, (UInt64)totalframes); + totalframes++; + writevideo(j); + } + + + + + + + + /// + /// assemble JMDPacket and send to packetqueue + /// + /// + void AddSamplesEx(short[] samples) + { + if (audiochannels == 1) + for (int i = 0; i < samples.Length; i++) + doaudiopacket(samples[i], samples[i]); + else + for (int i = 0; i < samples.Length / 2; i++) + doaudiopacket(samples[2 * i], samples[2 * i + 1]); + } + void doaudiopacket(short l, short r) + { + var j = new JMDPacket(); + j.stream = 1; + j.subtype = 1; // raw PCM audio + j.data = new byte[4]; + j.data[0] = (byte)(l >> 8); + j.data[1] = (byte)(l & 255); + j.data[2] = (byte)(r >> 8); + j.data[3] = (byte)(r & 255); + + j.timestamp = timestampcalc(audiosamplerate, 1, totalsamples); + totalsamples++; + writesound(j); + } + + /// + /// represents a JMD file packet ready to be written except for sorting and timestamp offset + /// + class JMDPacket + { + public UInt16 stream; + public UInt64 timestamp; // haven't subtracted timestampoffs yet + public byte subtype; + public byte[] data; + } + + /// + /// creates a timestamp out of fps value + /// + /// fpsnum + /// fpsden + /// frame position + /// + static UInt64 timestampcalc(int rate, int scale, UInt64 pos) + { + // rate/scale events per second + // timestamp is in nanoseconds + // round down, consistent with JPC-rr apparently? + var b = new System.Numerics.BigInteger(pos) * scale * 1000000000 / rate; + + return (UInt64)b; + } + /// + /// set metadata parameters; should be called before opening file + /// NYI + /// + public void SetMetaData(string gameName, string authors, UInt64 lengthMS, UInt64 rerecords) + { + this.gamename = gameName; + this.authors = authors; + this.lengthms = lengthMS; + this.rerecords = rerecords; + } + } +} diff --git a/BizHawk.MultiClient/MainForm.cs b/BizHawk.MultiClient/MainForm.cs index b22e9ccd25..a56ff3eab4 100644 --- a/BizHawk.MultiClient/MainForm.cs +++ b/BizHawk.MultiClient/MainForm.cs @@ -2666,7 +2666,7 @@ namespace BizHawk.MultiClient //TODO - cores should be able to specify exact values for these instead of relying on this to calculate them int fps = (int)(Global.Emulator.CoreOutputComm.VsyncRate * 0x01000000); - IVideoWriter aw = new AviWriter(); + IVideoWriter aw = new AviWriter(); // new JMDWriter(); try { aw.SetMovieParameters(fps, 0x01000000);