2013年3月16日

WAVE文件结构与《女仆咖啡帕露菲Re-Order》BGM提取

前几天在PSP上把《帕露菲》所有线都通了,以前玩过的《青空下的约定》也是GIGA做的。不得不说这个公司做GalGame是相当的厚道:可以无限制回溯上一选择肢,每个选择肢上都标注的有该事件的主要人物,一直选某一个角色的事件,就能走到该角色的结局。这个方法在攻略《青空》时百试不爽,不过《帕露菲》就不太行得通了。不过《帕露菲》也提供了更详细的选择肢提示:每个事件不仅有主要参与任务头像,还有该事件对结局的影响,例如:由飞TRUE NORMAL END;明日香日常事件。GIGA并不打算把玩家引入选择肢的迷宫在里面鬼打墙,而是让玩家把精力集中到品味剧本、音乐上来。从这个角度来看,GIGA算得上是业界良心,比那《恋爱蜡笔》不知高到哪里去了。
GIGA在OST这块圈钱不可谓不厉害,光《帕露菲》的就有两三个版本,《青空》的OST单独出了2CD还不过瘾,再拉上女仆咖啡系列出个SOUNDTRACK COMPLETE CD-BOX Music 共4CD。这还没完,《青空》后来又出了个PSP版(官方出品,我玩的这个《帕露菲》是民间移植的),又单独出一张OST,然后加上之前PC版的2CD,又出一个3CD的OST。
吐槽完了GIGA,回过头来说BGM提取。既然有这么多的OST,干嘛不直接收一个得了。这事得赖GIGA,出了那么多张OST,到头来还是没把游戏里的BGM收录完。下图橙色框中的音乐就都没出现在任何一张OST中。
这些未收录的曲子有一个特点,都不是GIGA拥有版权的。出去最后两个我没有考证,其他的曲子应该都是年代久远,不应该存在什么版权纠纷。《氷菓》都敢把《月光》、《西西里舞曲》收录到BD特典,GIGA又在担心什么呢?而这些未收录的曲子都相当不错,既然没有现成的,只好提取了。
我最先是拿PSP版的BGM.PAC文件开刀,于是还去学习了关于WAVE格式的知识。但遗憾的是虽然拿到了拆包后的WAVE文件,但几乎没有播放器能播放它,QQ影音勉强能打开,但十分卡顿,声音刺耳,勉强能听出是BGM的声音。在网上找了两个拆包的工具——ExtractData和Crass 0.4,但都没能成功提取。ExtractData提取到的跟我自己提取的一样,Crass则是什么都没提取到。
无奈之下,只好去装了个PC版的游戏,因为不少人已经用ExtractData成功提取到PC版的BGM。但我并没有立即拿这个工具去拆包——提取PSP版的失败后我已经把这两个工具都删了。这次就很顺利了,很快就把这个600多MB的PAC文件拆为39个WAVE文件。这种没有经过压缩或者加密的文件拆包其实是非常容易的,就是一个二进制文件读写的问题,关键在于从什么地方开始读,读多少字节。
关于WAVE格式的分析,我主要参考了这样一些文章:
  1. BlueSoal的专栏-wav文件格式分析详解
  2. ypynetboy的专栏-WAV文件格式分析
  3. FrankieWang008的专栏-RIFF文件格式
  4. 五维空间-采样率、比特率、采样位数、PCM、WAV
  5. 舒广,丁晓明.RIFF WAVE文件的结构及应用[J].电声技术,2001,(1):49-51.
不过这些文章或多或少都存在一些纰漏,大概是由于成文较久,目前的WAVE格式又有了新的发展吧。我把我注意到的地方提出来,以免别人再走我的弯路。下面我假设读者已经基本了解了WAVE格式的特点,例如知道WAVE格式由RIFF,Format,Fact,Data 4个Chunk组成。
首先是Fact Chunk,文章1中说这个区块是个可选区块,虽然没有说如果存在则必然位于Format Chunk之后,但也没有提到有其他的可能性。但实际上我在提取PSP版的BGM时就发现,这个Fact Chunk跑到Data Chunk之后去了。
然后是Format Chunk,这些文章基本都指明,Format Chunk中的Size要么为16,要么为18。PC版中确实是18,但PSP版中却有32,这也是PSP版BGM令我费解之一。另外文章中提到Format Chunk中的Format Tag通常为0x0001,表示PCM方式,但PSP版的却是0x0270。文章5列举了0x0001~0x0007共7种Format Tag的取值,但都没有0x0270。而采样位数BitsPerSample在PSP版中也为0,这点倒是没有令我太为震惊,文章4中说只有PCM方式时,这里才会有值。
我在PC版里还发现每个文件似乎都还有一个cue chunk,而上述文章都没有提到。这应该也是一个FOURRC,ID为cue ,后面的0x0034表示后面数据区的大小为52B,但这52B全是空的,什么也没有。
另外我还在PSP版中发现了LIST的区块,但内容好像是GoldWave的版权信息。我也不想去研究PSP版的BGM了,费力不讨好。最后是拆包的代码,如果只是提取这些音乐而不做更多的文件格式的详细分析,代码十分简单:分两步读取,首先从RIFF文件头开始读到其Size,根据Size的值确定第二步的读取需要读多少字节。两块合起来,写到一个文件里就可以了。注意所有数据的写入都要使用二进制写入方式,直接用BinaryWritter的Write()方法的各种重载是不行的,只能用Write(byte[] data)这一种,其余的数据需要转换成byte数组才可以。另外,像各个Chunk的Id在写入的时候编码最好选择ASCII,GBK或者UTF-8我没试过。
static void UnPack(string path,string outputPath)
{
    FileStream fs = File.OpenRead(path);
    BinaryReader br = new BinaryReader(fs);
    br.BaseStream.Seek(0, SeekOrigin.Begin);

    Console.WriteLine("Now working...\nTotal Length:" + br.BaseStream.Length);
    int offset = 12;
    //扔掉PAC文件前12字节,从RIFF开始读取
    br.ReadBytes(offset);
    int count = 1;
    while (br.BaseStream.Position < br.BaseStream.Length)
    {
        Wave wave = new Wave();
                
        #region RIFF Chunk
                
        //这个PAC文件最后还有一些其他数据,
        try
        {
            wave.RIFFChunk.Id = Common.Bytes2String(br.ReadBytes(4));
        }
        catch (ArgumentOutOfRangeException)
        {
            break;
        }
        wave.RIFFChunk.Size = BitConverter.ToInt32(br.ReadBytes(4), 0); 
        byte[] restData = br.ReadBytes(wave.RIFFChunk.Size);

        FileStream fs2 = new FileStream(outputPath+"BGM_"+count+".wav", FileMode.Create);
               
        BinaryWriter bw = new BinaryWriter(fs2);
        bw.Write(Encoding.ASCII.GetBytes(wave.RIFFChunk.Id));
        bw.Write(BitConverter.GetBytes(wave.RIFFChunk.Size));
        bw.Write(restData);
              
        bw.Flush();
        bw.Close();
        fs2.Close();
        Console.WriteLine(count+"bgm(s) extracted.");
        count++;
        #endregion
    }
    Console.WriteLine("Done!");
}
Wave这个类是我为了分析PSP版的BGM为什么会出问题而写的一个封装WAVE文件信息的类,如下:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace BGMUnPack
{
    public class Wave
    {

        public RIFF_WAVE_Chunk RIFFChunk;
        public Format_Chunk FormatChunk;
        public Fact_Chunk FactChunk;
        public Data_Chunk DataChunk;

        public Wave()
        {
            RIFFChunk = new RIFF_WAVE_Chunk();
            FormatChunk = new Format_Chunk();
            DataChunk = new Data_Chunk();
            FormatChunk.WaveFormat = new Wave_Format();
        }

        public void ToFile(string path)
        {
            FileStream fs = new FileStream(path, FileMode.Create);
            
            BinaryWriter bw = new BinaryWriter(fs);
            bw.Write(RIFFChunk.ToBytes());
            bw.Write(FormatChunk.ToBytes());
            if (FactChunk != null && FactChunk.Size != 0)
            {
                bw.Write(FactChunk.ToBytes());
            }
            bw.Write(DataChunk.ToBytes());
            bw.Flush();
            bw.Close();
            fs.Close();
        }

        public override string ToString()
        {
            string result = "";
            result += "采样率:" + FormatChunk.WaveFormat.SamplePerSec + "Hz\n"
                +"声道:"+FormatChunk.WaveFormat.Channels+"\n"
                +"采样位数:"+FormatChunk.WaveFormat.BitsPerSample
                +"比特率:"+FormatChunk.WaveFormat.AvgBytesPerSec;
            return result;
        }
    }

    public class RIFF_WAVE_Chunk
    {
        /// <summary>
        /// 4 Bytes, must be "RIFF"
        /// </summary>
        public string Id 
        {
            get { return "RIFF"; } 
            set {
                if (value != "RIFF")
                {
                    throw new ArgumentOutOfRangeException("Id", "Id must be RIFF");
                }
            } 
        }

        /// <summary>
        /// 4 Bytes which can convert to a Int32 that indicate the size of the wav file.
        /// Size=FileLength-8
        /// </summary>
        public int Size { get; set; }

        /// <summary>
        /// 4 Bytes, must be WAVE
        /// </summary>
        public string Format
        {
            get { return "WAVE"; }
            set
            {
                if (value != "WAVE")
                {
                    throw new ArgumentOutOfRangeException("Format", "Format must be WAVE");
                }
            }
        }

        public byte[] ToBytes()
        {
            byte[] result = new byte[8 + Size];
            result = Encoding.ASCII.GetBytes(Id)
                .Concat(BitConverter.GetBytes(Size))
                .Concat(Encoding.ASCII.GetBytes(Format))
                .ToArray();
            return result;
        }
    }

    public class Format_Chunk
    {
        string id;
        /// <summary>
        /// 4 Bytes, must start with "fmt"
        /// </summary>
        public string Id
        {
            get { return id; }
            set
            {
                if (!value.StartsWith("fmt"))
                {
                    throw new ArgumentOutOfRangeException("Id", "Id must start with 'fmt'");
                }
                id = value;
            }
        }

        /// <summary>
        /// 4 Bytes:16 or 18 (extra info in the end)
        /// </summary>
        public uint Size { get; set; }

        /// <summary>
        /// Wave format
        /// </summary>
        public Wave_Format WaveFormat { get; set; }

        /// <summary>
        /// optional Extra info, 2 Bytes, exisitence can judged by Size
        /// </summary>
        public byte[] Extra { get; set; }

        public byte[] ToBytes()
        {
            byte[] result =Encoding.ASCII.GetBytes(Id)
                .Concat(BitConverter.GetBytes(Size))
                .Concat(WaveFormat.ToBytes())
                .Concat(Extra)
                .ToArray();
            return result;
        }
    }

    public class Wave_Format
    {
        /// <summary>
        /// 2 Bytes, encoding manner, usually 0x0001
        /// </summary>
        public ushort FormatTag { get; set; }

        /// <summary>
        /// 2 Bytes. The number of the Channels. 1 means Mono, 2 means Dual Channel
        /// </summary>
        public ushort Channels { get; set; }

        /// <summary>
        /// 4 Bytes, the bitrate
        /// </summary>
        public uint SamplePerSec { get; set; }

        /// <summary>
        /// 4 Bytes, The bytes it need per second.
        /// </summary>
        public uint AvgBytesPerSec { get; set; }

        /// <summary>
        /// 2 Bytes, the unit of data block to align. 
        /// </summary>
        public ushort BlockAlign { get; set; }

        /// <summary>
        ///2 Bytes, the bytes every sampleing need.
        /// </summary>
        public ushort BitsPerSample { get; set; }

        public byte[] ToBytes()
        {
            byte[] result = new byte[16];
            result =BitConverter.GetBytes(FormatTag).
                Concat(BitConverter.GetBytes(Channels))
                .Concat(BitConverter.GetBytes(SamplePerSec))
                .Concat(BitConverter.GetBytes(AvgBytesPerSec))
                .Concat(BitConverter.GetBytes(BlockAlign))
                .Concat(BitConverter.GetBytes(BitsPerSample))
                .ToArray();
            return result;
        }
    }

    public class Fact_Chunk
    {
        /// <summary>
        /// 4 Bytes, if this chunk exist then must be "fact".
        /// </summary>
        public string Id
        {
            get { return "fact"; }    
            set
            {
                if (value != "fact")
                {
                    throw new ArgumentOutOfRangeException("Id","Id must be fact");
                }
            }
        }
        /// <summary>
        /// 4 Bytes, Size=4
        /// </summary>
        public uint Size { get; set; }

        /// <summary>
        /// 4 Bytes
        /// </summary>
        public byte[] Data { get; set; }

        public byte[] ToBytes()
        {
            byte[] result =Encoding.ASCII.GetBytes(Id)
                .Concat(BitConverter.GetBytes(Size))
                .Concat(Data)
                .ToArray();
            return result;
        }
    }

    public class Data_Chunk
    {
        /// <summary>
        /// 4Bytes, must be "data"
        /// </summary>
        public string Id 
        { 
            get { return "data"; }
            set 
            {
                if (value != "data")
                {
                    throw new ArgumentOutOfRangeException("Id","Id must be data");
                }
            }
        }

        /// <summary>
        /// size of Data Chunk
        /// </summary>
        public uint Size { get; set; }

        /// <summary>
        /// the data of wave file which it stored.
        /// </summary>
        public byte[] Data { get; set; }

        public byte[] ToBytes()
        {
            byte[] result = Encoding.ASCII.GetBytes(Id)
                .Concat(BitConverter.GetBytes(Size))
                .Concat(Data)
                .ToArray();
            return result;
        }
    }
}

没有评论:

发表评论