mp3のタグ(ID3v1, ID3v2)を読み込む（ライブラリ不使用）


前に使っていたmp3タグエディタがWindows10でなぜか正常に動作しなくなったので、自力で編集ツールを作ろうと思って調べてみた。
ID3v1は割と簡単に編集できそうだが、ID3v2は結構めんどくさそう・・・。ID3v2.2, v2.3, v2.4の微妙な仕様差異もめんどくさい・・・。

今回は、まずは単一ファイルの読み込みだけに対応してみた。

# 参考サイト ID3v1
- http://eleken.y-lab.org/report/other/mp3tags.shtml
- http://www.cactussoft.co.jp/Sarbo/divMPeg3UnmanageID3v1.html

# 参考サイト ID3v2
- http://www.cactussoft.co.jp/Sarbo/divMPeg3UnmanageID3v2.html
- http://eleken.y-lab.org/report/other/mp3tags.shtml
- http://tohka383.hatenablog.jp/entry/20120918/1347960578
- https://so-zou.jp/software/tech/file/format/mp3/
- http://takaaki.info/wp-content/uploads/2013/01/ID3v2.3.0J.html#sec3.2
- http://www.takaaki.info/wp-content/uploads/2013/01/id3v2_4_0-frames_j.txt
- https://id3.org/Developer%20Information
- https://ja.wikipedia.org/wiki/ID3%E3%82%BF%E3%82%B0

# キャプチャ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/11b8593b-0b08-3203-74fb-a2a651f6287f.png)


# ソースコード

pargeじゃなくてparseやんけ・・・orz　(∩´∀｀)∩

```csharp

using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;


public class ID3v1
{
    const int CodePage_Shift_JIS = 932;

    string[] Genres = {
        "Blues","ClassicRock","Country","Dance",
        "Disco","Funk","Grunge","Hip-Hop",
        "Jazz","Metal","NewAge","Oldies",
        "Other","Pop","R&B","Rap",
        "Reggae","Rock","Techno","Industrial",
        "Alternative","Ska","DeathMetal","Pranks",
        "Soundtrack","Euro-Techno","Ambient","Trip-Hop",
        "Vocal","Jazz+Funk","Fusion","Trance",
        "Classical","Instrumental","Acid","House",
        "Game","SoundClip","Gospel","Noise",
        "Alt.Rock","Bass","Soul","Punk",
        "Space","Meditative","InstrumentalPop","InstrumentalRock",
        "Ethnic","Gothic","Darkwave","Techno-Industrial",
        "Electronic","Pop-Folk","Eurodance","Dream",
        "SouthernRock","Comedy","Cult","Gangsta",
        "Top40","ChristianRap","Pop/Funk","Jungle",
        "NativeAmerican","Cabaret","NewWave","Psychadelic",
        "Rave","Showtunes","Trailer","Lo-Fi",
        "Tribal","AcidPunk","AcidJazz","Polka",
        "Retro","Musical","Rock&Roll","HardRock",
        "Folk","Folk/Rock","NationalFolk","Swing",
        "Fusion","Bebob","Latin","Revival",
        "Celtic","Bluegrass","Avantgarde","GothicRock",
        "ProgressiveRock","PsychedelicRock","SymphonicRock","SlowRock",
        "BigBand","Chorus","EasyListening","Acoustic",
        "Humour","Speech","Chanson","Opera",
        "ChamberMusic","Sonata","Symphony","BootyBass",
        "Primus","PornGroove","Satire","SlowJam",
        "Club","Tango","Samba","Folklore",
        "Ballad","Power Ballad","Rhytmic Soul","Freestyle",
        "Duet","Punk Rock","Drum Solo","Acapella",
        "Euro-House","Dance Hall","Goa","Drum & Bass",
        "Club-House","Hardcore","Terror","Indie",
        "BritPop","Negerpunk","Polsk Punk","Beat",
        "Christian Gangsta Rap","Heavy Metal","Black Metal","Crossover",
        "Contemporary Christian","Christian Rock","Merengue","Salsa",
        "Trash Metal","Anime","JPop","SynthPop",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "(reserved)","(reserved)","(reserved)","(reserved)",
        "Sacred","Northern Europe","Irish & Scottish","Scotland",
        "Ethnic Europe","Enka","Children's Song","(reserved)",
        "Heavy Rock(J)","Doom Rock(J)","J-POP(J)","Seiyu(J)",
        "Tecno Ambient(J)","Moemoe(J)","Tokusatsu(J)","Anime(J)", // 255 ("Anime(J)") は本来「不使用」がアサインされている(?)
    };

    // null終端文字列の扱い
    // https://www.ipentec.com/document/csharp-null-terminate-string-trimming
    byte[] TitleInBytes;
    byte[] ArtistInBytes;
    byte[] AlbumInBytes;
    byte[] YearInByte;
    byte[] CommnetInByte;
    byte GenreNumber; // ジャンル
    public int Year {
        get { 
            try {
                return Convert.ToInt32(Encoding.ASCII.GetString(YearInByte));
            }
            catch(Exception){return 0;}
        }
    }

    string TryToGetString(byte[] t)
    {
        try {
            return Encoding.GetEncoding(CodePage_Shift_JIS).GetString(t).TrimEnd(new char[]{'\0',' '});
        }
        catch(Exception){return "";}
    }

    public string Title   { get { return TryToGetString(TitleInBytes); } }
    public string Artist  { get { return TryToGetString(ArtistInBytes); } }
    public string Album   { get { return TryToGetString(AlbumInBytes); } }
    public string Comment { get { return TryToGetString(CommnetInByte); } }
    public string Genre {
        get {return Genres[GenreNumber];}
    }
    public int Track{get;private set;}


    public static ID3v1 PargeFromFoot(byte[] buffer)
    {
        return Parge(buffer, buffer.Length-128);
    }

    public static ID3v1 Parge(byte[] buffer, int offset)
    {
        ID3v1 ret = new ID3v1();
        if ( offset < 0 ) {
            return null;
        }
        if (buffer.Length < offset + 128 ) {
            return null;
        }

        // ヘッダチェック ("TAG")
        if ( buffer[offset] != 0x54 || buffer[offset+1] != 0x41 || buffer[offset+2] != 0x47) {
            return null;
        }
        ret.TitleInBytes = new byte[30];
        ret.ArtistInBytes = new byte[30];
        ret.AlbumInBytes = new byte[30];
        ret.YearInByte = new byte[4];
        Array.Copy(buffer, offset+ 3, ret.TitleInBytes,  0, 30);
        Array.Copy(buffer, offset+33, ret.ArtistInBytes, 0, 30);
        Array.Copy(buffer, offset+63, ret.AlbumInBytes,  0, 30);
        Array.Copy(buffer, offset+93, ret.YearInByte,    0,  4);
        if ( buffer[offset+125] == 0x00 ) { // Track情報あり
            ret.CommnetInByte = new byte[28];
            Array.Copy(buffer, offset+97, ret.CommnetInByte, 0, 28);
            ret.Track = buffer[offset+126];
        }
        else {
            ret.CommnetInByte = new byte[30];
            Array.Copy(buffer, offset+97, ret.CommnetInByte, 0, 30);
            ret.Track = 0;
        }
        ret.GenreNumber = buffer[offset+127];

        return ret;
    }
}

public class ID3v2
{
    const int CodePage_Shift_JIS = 932;


    static int SynchsafeIntFrom4Bytes(byte[] buffer, int offset) {
        return ((buffer[offset  ]&0x7F)<<21) |
               ((buffer[offset+1]&0x7F)<<14) | 
               ((buffer[offset+2]&0x7F)<< 7) | 
               ((buffer[offset+3]&0x7F)    ) ;
    }

    public class Frame
    {
        public string ID{get;private set;}
        int MajorVer;
        int Size;
        int EncodingID;
        byte Flags1;
        byte Flags2;
        byte[] Data; // EncodingIDを除く

        public override string ToString()
        {
            int offset = 0;

            if ( ID == "PIC" || ID == "APIC" ) {
                return "";
            }

            if ( ID == "COM" || ID == "COMM" ) {
                return ""; // 未実装
                /*
                if ( Data.Length >= 3+1 && 'a' <= Data[0] && Data[0] <= 'z' ) {
                    // 国別コードらしきものがある場合
                    offset = 3;
                    if ( EncodingID == 1 ) {
                        // BOM 2byteすてて 2byteずつサーチしてNULL終端0x00 00を探す
                    }
                    else if ( EncodingID == 2 ) {
                        // 2byteずつサーチしてNULL終端0x00 00を探す
                    }
                    else if ( EncodingID == 0 || EncodingID == 3 ) {
                        // NULL終端0x00を探す
                    }
                }
                else {
                    return ""; // 不正なフォーマット
                }
                */
            }

            try {
                if ( EncodingID == 3 ) {
                    // UTF-8 BOMなし
                    return (new System.Text.UTF8Encoding(false)).GetString(Data, offset, Data.Length-offset);
                }
                else if ( EncodingID == 2 ) {
                    // UTF-16BE BOMなし
                    return (new System.Text.UnicodeEncoding(true,false)).GetString(Data, offset, Data.Length-offset);
                }
                else if ( EncodingID == 1 ) {
                    // UTF-16 BOMあり
                    return (new System.Text.UnicodeEncoding()).GetString(Data, offset, Data.Length-offset);
                }
                else if ( EncodingID == 0 ) {
                    // MPEGの規格上は
                    //   ISO-8859-1 (CodePage=28591 西ヨーロッパ言語 (ISO))
                    //return Encoding.GetEncoding(28591).GetString(Data);

                    // 日本ではShift_JISが横行しているらしい(?)
                    //   shift_jis(CodePage=932)
                    return Encoding.GetEncoding(CodePage_Shift_JIS).GetString(Data, offset, Data.Length-offset);
                }
                else {
                    // unknown
                    return "";
                }
            }
            catch ( DecoderFallbackException ) {
                // 読み取り不能
                return "";
            }
        }

        public static Frame Parse(int majorVer, byte[] buffer, ref int pos, int endPos)
        {
            Frame ret = new Frame();

            if ( endPos > buffer.Length ){ return null; }

            if ( endPos < pos+6 ) { return null; }
            if ( majorVer >= 3 && endPos < pos+10 ) { return null; }

            ret.MajorVer = majorVer;
            try {
                ret.ID = Encoding.ASCII.GetString(buffer, pos, (majorVer<=2)?3:4 );
            }
            catch ( DecoderFallbackException ) {
                // 読み取り不能
                Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                return null;
            }

            if ( majorVer <= 2 ) {
                ret.Size = (buffer[pos+3]<<16) | (buffer[pos+4]<<8) | (buffer[pos+5]);
                pos += 6;
            }
            else {
                if ( majorVer <= 3 ) {
                    ret.Size = (buffer[pos+4]<<24) |
                               (buffer[pos+5]<<16) | 
                               (buffer[pos+6]<<8) |
                               (buffer[pos+7]);
                }
                else {
                    ret.Size = SynchsafeIntFrom4Bytes(buffer, pos+4);
                }
                ret.Flags1 = buffer[pos+8];
                ret.Flags2 = buffer[pos+9];
                pos += 10;
            }
            if ( endPos < pos + ret.Size ) {
                Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                Console.WriteLine("Address over");
                return null;
            }
            if ( ret.Size > 0 ) {
                ret.Data = new byte[ret.Size-1];
                ret.EncodingID = buffer[pos];
                Array.Copy(buffer, pos+1, ret.Data, 0, ret.Size-1);
            }
            else {
                ret.Data = new byte[0];
                ret.EncodingID = 0;
            }
            pos += ret.Size;
            
            return ret;
        }
    }

    public int TagVerMajor{get;private set;}
    public int TagVerMinor{get;private set;}
    public int Flags{get;private set;}
    public int TagSize{get;private set;}
    public int ExtSize{get;private set;}

    public bool HasExtendedHeader{get{return ((Flags&0x40)!=0);}}
    public bool HasFooter{get{return ((Flags&0x10)!=0);}}

    List<Frame> Frames;
    
    int FindFirstFrameByID(string FrameID) {
        for(int i=0;i<Frames.Count;i++){
            if ( Frames[i].ID == FrameID ) {
                return i;
            }
        }
        return -1;
    }

    public string Artist { get { return GetStringByID("TP1","TPE1"); } }
    public string Title { get { return GetStringByID("TT2","TIT2"); } }
    public string Album { get { return GetStringByID("TAL","TALB"); } }
    public string Track { get { return GetStringByID("TRK","TRCK"); } }
    public string Year { get { return GetStringByID("TYE","TYER"); } }
    public string Genre { get { return GetStringByID("TCO","TCON"); } }
    public string Comment { get { return GetStringByID("COM","COMM"); } }

    string GetStringByID(string idForV3p2, string idForV3p3)
    {
        string id = (TagVerMajor<=2)?idForV3p2:idForV3p3;
        if ( id == null ) { return ""; }
        int index = FindFirstFrameByID(id);
        if ( index < 0 ) { return ""; }
        return Frames[index].ToString();
    }
            
    public static ID3v2 Parge(byte[] buffer, int offset)
    {
        ID3v2 ret = new ID3v2();

        if ( offset < 0 ) {
            return null;
        }
        // ID3v2は最低でも10byte以上なので10byte以上であることをチェックする
        if (buffer.Length < offset + 10 ) {
            return null;
        }

        // ヘッダチェック "ID3"
        if ( buffer[offset] != 0x49 || buffer[offset+1] != 0x44 || buffer[offset+2] != 0x33) {
            return null;
        }

        ret.TagVerMajor = buffer[offset+3];
        ret.TagVerMinor = buffer[offset+4];
        ret.Flags = buffer[offset+5];
        ret.TagSize = SynchsafeIntFrom4Bytes(buffer, offset+6);

        int pos = offset+10;
        int endPos = pos + ret.TagSize;
        if ( endPos > buffer.Length ) {
            return null;
        }
        if ( ret.HasFooter ) {
            endPos -= 10; // Footer(10byte)分末尾位置を手前にセットする
        }

        if ( ret.HasExtendedHeader ) {
            // 最小6byteある
            if ( buffer.Length < pos+6 ) {
                return null;
            }
            if ( ret.TagVerMajor <= 3 ) {
                // IDv2.3.x以下
                ret.ExtSize = (buffer[pos  ]<<24) |
                              (buffer[pos+1]<<16) |
                              (buffer[pos+2]<< 8) |
                              (buffer[pos+3]    );
            }
            else {
                ret.ExtSize = SynchsafeIntFrom4Bytes(buffer, pos);
            }
            pos += 4 + ret.ExtSize;
        }

        // parsing Frame
        ret.Frames = new List<Frame>();
        while ( pos < endPos ) {
            if ( buffer[pos] == 0 ) { // padding領域(っぽい)を検出
                break;
            }
            Frame t = Frame.Parse(ret.TagVerMajor, buffer, ref pos, endPos);
            if ( t == null ) {
                Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                return null;
            }
            else {
                ret.Frames.Add(t);
            }
        }
        return ret;
    }
}


class MainForm : Form
{
    ListView lsvID3v1;
    ListView lsvID3v2;

    MainForm(string filePath)
    {
        Text = "Mp3TagViewer";

        Controls.Add(
            lsvID3v1 = new ListView() {
                Location = new Point(0, 0),
                Size = new Size(600, 200),
                View = View.Details,
                FullRowSelect = true,
                GridLines = true,
                AllowDrop = true,
            }
        );
        lsvID3v1.Columns.Add("ID3v1項目", 150);
        lsvID3v1.Columns.Add("値", 250);
        lsvID3v1.DragEnter += Control_DragEnter;
        lsvID3v1.DragDrop += Control_DragDrop;
        
        Controls.Add(
            lsvID3v2 = new ListView() {
                Location = new Point(0, 200),
                Size = new Size(600, 400),
                View = View.Details,
                FullRowSelect = true,
                GridLines = true,
                AllowDrop = true,
            }
        );
        lsvID3v2.Columns.Add("ID3v2項目", 150);
        lsvID3v2.Columns.Add("値", 250);
        lsvID3v2.DragEnter += Control_DragEnter;
        lsvID3v2.DragDrop += Control_DragDrop;

        this.AllowDrop = true;
        this.DragEnter += Control_DragEnter;
        this.DragDrop += Control_DragDrop;


        if ( filePath != null ) {
            LoadFile(filePath);
        }
        
        ClientSize = new Size(600,650);
    }
    
    void Control_DragEnter(Object sender, DragEventArgs e)
    {
        if (e.Data.GetDataPresent(DataFormats.FileDrop)) {
            e.Effect = DragDropEffects.Copy;
        }
        else {
            e.Effect = DragDropEffects.None;
        }
    }
    
    void Control_DragDrop(Object sender, DragEventArgs e)
    {
        var fileNames = (string[])e.Data.GetData(DataFormats.FileDrop, false);
        if ( fileNames != null && fileNames.Length == 1 ) {
            if ( fileNames[0].EndsWith(".mp3", true, null) ) {// Note: 第2引数はignoreCase
                LoadFile(fileNames[0]);
            }
        }
    }

    void LoadFile(string filePath)
    {
        if ( filePath.EndsWith(".mp3", true, null) ) {// Note: 第2引数はignoreCase
            byte[] data = File.ReadAllBytes(filePath);
            ID3v1 id3v1 = ID3v1.PargeFromFoot(data);
            ID3v2 id3v2 = ID3v2.Parge(data,0);

            RegisterID3v1ToControl(id3v1);
            RegisterID3v2ToControl(id3v2);
        }
    }

    
    void RegisterID3v1ToControl(ID3v1 id3v1)
    {
        lsvID3v1.Items.Clear();
        if ( id3v1 != null ) { 
            lsvID3v1.BeginUpdate();
            try {
                lsvID3v1.Items.AddRange(
                    new ListViewItem[]{
                        new ListViewItem(new string[]{"アーティスト", id3v1.Artist}),
                        new ListViewItem(new string[]{"アルバム", id3v1.Album}),
                        new ListViewItem(new string[]{"トラック", id3v1.Track.ToString()}),
                        new ListViewItem(new string[]{"曲名", id3v1.Title}),
                        new ListViewItem(new string[]{"年", id3v1.Year.ToString()}),
                        new ListViewItem(new string[]{"ジャンル", id3v1.Genre}),
                        new ListViewItem(new string[]{"コメント", id3v1.Comment}),
                    }
                );
            }
            finally {
                lsvID3v1.EndUpdate();
            }
        }
    }

    void RegisterID3v2ToControl(ID3v2 id3v2)
    {
        lsvID3v2.Items.Clear();
        if ( id3v2 != null ) { 
            lsvID3v2.BeginUpdate();
            try {
                lsvID3v2.Items.AddRange(
                    new ListViewItem[]{
                        new ListViewItem(new string[]{"アーティスト", id3v2.Artist}),
                        new ListViewItem(new string[]{"アルバム", id3v2.Album}),
                        new ListViewItem(new string[]{"トラック", id3v2.Track}),
                        new ListViewItem(new string[]{"曲名", id3v2.Title}),
                        new ListViewItem(new string[]{"年", id3v2.Year}),
                        new ListViewItem(new string[]{"ジャンル", id3v2.Genre}),
                        //new ListViewItem(new string[]{"コメント", id3v2.Comment}),
                    }
                );
            }
            finally {
                lsvID3v2.EndUpdate();
            }
        }
    }


    [STAThread]
    static void Main(string[] args)
    {
        Application.Run(new MainForm((args.Length==1)?args[0]:null));
    }
}

```