前に使っていた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
キャプチャ
ソースコード
pargeじゃなくてparseやんけ・・・orz (∩´∀`)∩
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));
}
}