diff --git a/.editorconfig b/.editorconfig
index 95e7bc9..8e233c6 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -81,14 +81,14 @@ csharp_style_var_for_built_in_types = true:silent
csharp_style_var_when_type_is_apparent = true:silent
# 式のようなメンバー
-csharp_style_expression_bodied_accessors = true
-csharp_style_expression_bodied_constructors = false
-csharp_style_expression_bodied_indexers = true
-csharp_style_expression_bodied_lambdas = true
-csharp_style_expression_bodied_local_functions = false
-csharp_style_expression_bodied_methods = false
-csharp_style_expression_bodied_operators = false
-csharp_style_expression_bodied_properties = true
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
# パターン マッチング設定
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
@@ -102,7 +102,7 @@ csharp_style_prefer_switch_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# 修飾子設定
-csharp_prefer_static_local_function = true
+csharp_prefer_static_local_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
# コード ブロックの設定
@@ -113,27 +113,27 @@ csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
# 式レベルの設定
-csharp_prefer_simple_default_expression = true
-csharp_style_deconstructed_variable_declaration = true
-csharp_style_implicit_object_creation_when_type_is_apparent = true
-csharp_style_inlined_variable_declaration = true
-csharp_style_prefer_index_operator = true
-csharp_style_prefer_local_over_anonymous_function = true
-csharp_style_prefer_null_check_over_type_check = true
-csharp_style_prefer_range_operator = true
-csharp_style_prefer_tuple_swap = true
-csharp_style_prefer_utf8_string_literals = true
-csharp_style_throw_expression = true
-csharp_style_unused_value_assignment_preference = discard_variable
-csharp_style_unused_value_expression_statement_preference = discard_variable
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' ディレクティブの基本設定
csharp_using_directive_placement = outside_namespace:silent
# 改行設定
-csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
-csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
-csharp_style_allow_embedded_statements_on_same_line_experimental = true
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
#### C# 書式ルール ####
@@ -224,6 +224,10 @@ dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
[*.{cs,vb}]
tab_width = 4
@@ -237,5 +241,27 @@ dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_operator_placement_when_wrapping = beginning_of_line
+end_of_line = lf
dotnet_code_quality_unused_parameters = all:suggestion
-end_of_line = crlf
\ No newline at end of file
+dotnet_style_readonly_field = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_prefer_collection_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = false:silent
+dotnet_style_prefer_conditional_expression_over_return = false:silent
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 01116e7..9f17d8b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,4 +1,4 @@
-name: .NET Core Desktop
+name: Build and Test
on:
push:
diff --git a/.gitignore b/.gitignore
index d440c1d..b111e13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -362,3 +362,6 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/Shinko.cs
+
+# VS Code workspace
+SoundMaker.code-workspace
\ No newline at end of file
diff --git a/SoundMaker.sln b/SoundMaker.sln
index 5472aa1..2606294 100644
--- a/SoundMaker.sln
+++ b/SoundMaker.sln
@@ -9,6 +9,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SoundMakerTests", "test\Sou
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SoundMakerConsole", "src\SoundMakerConsole\SoundMakerConsole.csproj", "{56C904EC-478C-4219-9599-63C1F2041DB4}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ソリューション項目", "ソリューション項目", "{A79A7D6C-7F61-4B70-B921-18678D37B354}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ .gitignore = .gitignore
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
diff --git a/src/SoundMaker/ForTests.cs b/src/SoundMaker/ForTests.cs
new file mode 100644
index 0000000..f3430d9
--- /dev/null
+++ b/src/SoundMaker/ForTests.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("SoundMakerTests")]
diff --git a/src/SoundMaker/ScoreData/SMSC/Error.cs b/src/SoundMaker/ScoreData/SMSC/Error.cs
new file mode 100644
index 0000000..bfcde3b
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/Error.cs
@@ -0,0 +1,28 @@
+namespace SoundMaker.ScoreData.SMSC;
+public record Error
+{
+ internal Error(SMSCReadErrorType type, Token? token)
+ {
+ Type = type;
+ LineNumber = token?.LineNumber ?? 0;
+ Literal = token?.Literal ?? "";
+ }
+
+ ///
+ /// Type of errors
+ /// エラーの種類
+ ///
+ public SMSCReadErrorType Type { get; }
+
+ ///
+ /// String of error location
+ /// エラー箇所の文字列
+ ///
+ public string Literal { get; }
+
+ ///
+ /// Line number of error.
+ /// エラー箇所の行番号
+ ///
+ public int LineNumber { get; }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/Lexer.cs b/src/SoundMaker/ScoreData/SMSC/Lexer.cs
new file mode 100644
index 0000000..9c693f6
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/Lexer.cs
@@ -0,0 +1,162 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SoundMaker.ScoreData.SMSC;
+internal class Lexer
+{
+ private readonly Regex _alphaRegex = new("[a-z]|[A-Z]");
+
+ private readonly string _data = "";
+
+ public Lexer(string data)
+ {
+ _data = data;
+ }
+
+ public List ReadAll()
+ {
+ var tokens = new List();
+ var data = _data.Replace("\r\n", "\n").Replace('\r', '\n');
+ // 空白、空文字列、コメントだけの行をあらかじめ除外する
+ var lines = data.Split('\n').ToArray();
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+
+ // 空白またはコメントだけの行はスキップする
+ if (string.IsNullOrWhiteSpace(line) || line.StartsWith("//"))
+ {
+ continue;
+ }
+
+ var lineNumber = i + 1;
+ var chars = line.ToCharArray();
+ var otherTypeLiteralBuilder = new StringBuilder();
+ var numberLiteralBuilder = new StringBuilder();
+ for (var j = 0; j < chars.Length; j++)
+ {
+ char? next = j + 1 < chars.Length ? chars[j + 1] : null;
+ // numbers
+ if (char.IsNumber(chars[j]))
+ {
+ _ = numberLiteralBuilder.Append(chars[j]);
+ if (otherTypeLiteralBuilder.Length != 0)
+ {
+ var literal = otherTypeLiteralBuilder.ToString();
+ var type = MatchOtherType(literal);
+ tokens.Add(new(type, otherTypeLiteralBuilder.ToString(), lineNumber));
+ _ = otherTypeLiteralBuilder.Clear();
+ }
+ continue;
+ }
+ // others
+ if (IsOtherChar(chars[j], next))
+ {
+ _ = otherTypeLiteralBuilder.Append(chars[j]);
+ if (numberLiteralBuilder.Length != 0)
+ {
+ var literal = numberLiteralBuilder.ToString();
+ tokens.Add(new(TokenType.Number, literal, lineNumber));
+ _ = numberLiteralBuilder.Clear();
+ }
+ continue;
+ }
+
+ if (numberLiteralBuilder.Length != 0)
+ {
+ var literal = numberLiteralBuilder.ToString();
+ tokens.Add(new(TokenType.Number, literal, lineNumber));
+ _ = numberLiteralBuilder.Clear();
+ }
+ if (otherTypeLiteralBuilder.Length != 0)
+ {
+ var literal = otherTypeLiteralBuilder.ToString();
+ var type = MatchOtherType(literal);
+ tokens.Add(new(type, literal, lineNumber));
+ _ = otherTypeLiteralBuilder.Clear();
+ }
+
+ // comment out
+ if (j + 1 < chars.Length && IsCommentPrefix(chars[j], next))
+ {
+ break;
+ }
+
+ // space
+ if (char.IsWhiteSpace(chars[j]))
+ {
+ continue;
+ }
+
+ // symbols
+ Token token = chars[j] switch
+ {
+ '.' => new(TokenType.Dot, ".", lineNumber),
+ '#' => new(TokenType.Sharp, "#", lineNumber),
+ '(' => new(TokenType.LeftParentheses, "(", lineNumber),
+ ')' => new(TokenType.RightParentheses, ")", lineNumber),
+ ',' => new(TokenType.Comma, ",", lineNumber),
+ ';' => new(TokenType.Semicolon, ";", lineNumber),
+ _ => new(TokenType.Unknown, chars[j].ToString(), lineNumber),
+ };
+ tokens.Add(token);
+ }
+
+ if (numberLiteralBuilder.Length != 0)
+ {
+ var literal = numberLiteralBuilder.ToString();
+ tokens.Add(new(TokenType.Number, literal, lineNumber));
+ _ = numberLiteralBuilder.Clear();
+ }
+
+ if (otherTypeLiteralBuilder.Length != 0)
+ {
+ var literal = otherTypeLiteralBuilder.ToString();
+ var type = MatchOtherType(literal);
+ tokens.Add(new(type, literal, lineNumber));
+ _ = otherTypeLiteralBuilder.Clear();
+ }
+
+ tokens.Add(new(TokenType.LineBreak, "\n", lineNumber));
+ }
+ return tokens;
+ }
+
+ private TokenType MatchOtherType(string other)
+ {
+ if (int.TryParse(other, out _))
+ {
+ return TokenType.Number;
+ }
+ if (_alphaRegex.IsMatch(other))
+ {
+ return other switch
+ {
+ "tie" => TokenType.Tie,
+ "tup" => TokenType.Tuplet,
+ "rest" => TokenType.Rest,
+ _ => TokenType.Alphabet,
+ };
+ }
+ return TokenType.Unknown;
+ }
+
+ private bool IsOtherChar(char character, char? nextCharacter)
+ {
+ var next = nextCharacter ?? 'a'; // 'a' is not comment prefix.
+ return
+ !IsSymbol(character) &&
+ !IsCommentPrefix(character, next) &&
+ !char.IsWhiteSpace(character);
+ }
+
+ private bool IsCommentPrefix(char character, char? nextCharacter)
+ {
+ return character is '/' && nextCharacter is '/';
+ }
+
+ private bool IsSymbol(char character)
+ {
+ return character is '.' or '#' or '(' or ')' or ',' or ';';
+ }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/Parser.cs b/src/SoundMaker/ScoreData/SMSC/Parser.cs
new file mode 100644
index 0000000..14db2d4
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/Parser.cs
@@ -0,0 +1,478 @@
+using SoundMaker.Sounds.Score;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SoundMaker.ScoreData.SMSC;
+internal class Parser
+{
+ private record LengthResult(LengthType LengthType, bool IsDotted);
+
+ private record ScaleResult(Scale Scale, int ScaleNumber);
+
+ private record ParseResult(T? Value, Error? Error) where T : class
+ {
+ public bool TryGetValue([MaybeNullWhen(false)] out T value)
+ {
+ if (Value is null || Error is not null)
+ {
+ value = null;
+ return false;
+ }
+ value = Value;
+ return true;
+ }
+ }
+
+ private readonly TokenQueue _tokens;
+
+ public Parser(IEnumerable tokens)
+ {
+ _tokens = new(tokens);
+ }
+
+ ///
+ /// 解析する
+ ///
+ /// 解析結果
+ public SMSCReadResult Parse()
+ {
+ var statements = new List();
+ var errors = new List();
+ while (_tokens.Count > 0)
+ {
+ var statementResultNullable = ParseStatement();
+
+ // 文末だった場合はスキップする。
+ if (statementResultNullable is null)
+ {
+ continue;
+ }
+
+ if (statementResultNullable.TryGetValue(out var statement))
+ {
+ statements.Add(statement);
+ }
+ else if (statementResultNullable.Error is not null)
+ {
+ errors.Add(statementResultNullable.Error);
+ }
+ }
+ var result = errors.Any() ?
+ SMSCReadResult.Failure(errors) :
+ SMSCReadResult.Success(statements);
+ return result;
+ }
+
+ ///
+ /// 文を解析する
+ ///
+ /// 解析結果(文末だった場合はnull)
+ private ParseResult? ParseStatement()
+ {
+ // 現在のトークンの種類を見たいだけなのでPeekする。
+ if (!_tokens.TryPeek(out var current))
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+
+ ParseResult? statementResult = null;
+ // 文末ではない場合のみ解析を行う。
+ if (!IsEndOfStatement(current))
+ {
+ // 文の解析を現在のトークンに基づいて行う。
+ statementResult = current.Type switch
+ {
+ TokenType.Tie => ParseTie(),
+ TokenType.Tuplet => ParseTuplet(),
+ TokenType.Rest => ParseRest(),
+ // 上記に当てはまらない場合は音符として解析する。
+ _ => ParseNote()
+ };
+ }
+
+ // 文の終わりまで進める
+ while (_tokens.TryDequeue(out current))
+ {
+ if (IsEndOfStatement(current))
+ {
+ break;
+ }
+ }
+
+ return statementResult;
+ }
+
+ ///
+ /// タイを解析する
+ ///
+ /// 解析結果
+ private ParseResult ParseTie()
+ {
+ // トークンが'tie'かを確認する
+ if (!_tokens.TryDequeue(out var current) || current.Type is not TokenType.Tie)
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+ // トークンが'('かを確認する
+ if (!_tokens.TryDequeue(out current) || current.Type is not TokenType.LeftParentheses)
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundLeftParentheses, _tokens.PrevToken));
+ }
+ // 音程を解析する
+ var scaleResult = ParseScale();
+ if (!scaleResult.TryGetValue(out var scale))
+ {
+ return new(null, scaleResult.Error);
+ }
+
+ // 引数内の長さを表す情報を解析する。トークンがなくなるか')'や文の終わりになるまで解析する。
+ // 長さの情報は可変長引数として取る。
+ var notes = new List();
+ while (_tokens.TryPeek(out current) && current.Type is not TokenType.RightParentheses && !IsEndOfStatement(current))
+ {
+ // ','の場合はDequeueする。tie(音程, 長さ, 長さ, ...)という並びなので、while文の先頭にこの処理を置く。
+ // 音程解析はwhileの前で実装済みなので、", 長さ, 長さ, ..."という部分をここで解析する。
+ if (current.Type is TokenType.Comma)
+ {
+ _ = _tokens.Dequeue();
+ }
+ else
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundComma, current));
+ }
+
+ // 長さを解析する。
+ var lengthResult = ParseLength();
+ if (!lengthResult.TryGetValue(out var length))
+ {
+ return new(null, lengthResult.Error);
+ }
+
+ // 音符を作成する。
+ var note = new Note(scale.Scale, scale.ScaleNumber, length.LengthType, length.IsDotted);
+ notes.Add(note);
+ }
+ // ')'でない場合、エラーを出力。
+ if (!_tokens.TryPeek(out current) || current.Type is not TokenType.RightParentheses)
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundRightParentheses, _tokens.PrevToken));
+ }
+
+ // ')'を破棄
+ _ = _tokens.Dequeue();
+
+ // タイを作成する。
+ Tie tie;
+ // 音符が0個の場合は解析できていない。
+ if (notes.Count == 0)
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+ // 個数が1個の場合はadditionalNotesに空配列を渡す
+ else if (notes.Count == 1)
+ {
+ tie = new(notes[0], Array.Empty());
+ }
+ // 個数が2個以上の場合は、先頭をbaseとし、残りをadditionalNotesとする。
+ else
+ {
+ tie = new(notes[0], notes.GetRange(1, notes.Count - 1));
+ }
+ // 解析結果を返す。
+ return new(tie, null);
+ }
+
+ ///
+ /// 連符を解析する
+ ///
+ /// 解析結果
+ private ParseResult ParseTuplet()
+ {
+ // トークンが'tup'かを確認する
+ if (!_tokens.TryDequeue(out var current) || current.Type is not TokenType.Tuplet)
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+ // トークンが'('かを確認する
+ if (!_tokens.TryDequeue(out current) || current.Type is not TokenType.LeftParentheses)
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundLeftParentheses, _tokens.PrevToken));
+ }
+ // 長さを解析する
+ var lengthResult = ParseLength();
+
+ // 連符にするSoundComponentを解析する
+ var components = new List();
+ // 引数内の長さを表す情報を解析する。トークンがなくなるか')'や文の終わりになるまで解析する。
+ // 情報は可変長引数として取る。
+ while (_tokens.TryPeek(out current) && current.Type is not TokenType.RightParentheses && !IsEndOfStatement(current))
+ {
+ // ','の場合はDequeueする。tup(長さ, 音の部品, 音の部品, ...)という並びなので、while文の先頭にこの処理を置く。
+ // 長さの解析はwhileの前で実装済みなので、", 音の部品, 音の部品, ..."という部分をここで解析する。
+ if (current.Type is TokenType.Comma)
+ {
+ _ = _tokens.Dequeue();
+ }
+ else
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundComma, current));
+ }
+
+ // 条件分岐の為にトークンをピークする
+ if (!_tokens.TryPeek(out current))
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+
+ // タイの場合はタイを解析する
+ if (current.Type is TokenType.Tie)
+ {
+ var componentResult = ParseTie();
+ if (!componentResult.TryGetValue(out var component))
+ {
+ return new(null, componentResult.Error);
+ }
+ components.Add(component);
+ }
+ // 連符の場合は連符を解析(再帰呼び出し)する。
+ else if (current.Type is TokenType.Tuplet)
+ {
+ var componentResult = ParseTuplet();
+ if (!componentResult.TryGetValue(out var component))
+ {
+ return new(null, componentResult.Error);
+ }
+ components.Add(component);
+ }
+ // 休符の場合は休符を解析するが、連符専用の書き方なのでここで実装する。
+ else if (current.Type is TokenType.Rest)
+ {
+ // 'rest'のトークンを破棄
+ _ = _tokens.Dequeue();
+ // 連符なので長さは適当に入れる
+ var rest = new Rest(LengthType.Whole, false);
+ components.Add(rest);
+ }
+ // 上記以外は音符として解析するが、連符専用の書き方なのでここで実装する。
+ else
+ {
+ // 音程を解析する
+ var scaleResult = ParseScale();
+ if (!scaleResult.TryGetValue(out var scale))
+ {
+ return new(null, scaleResult.Error);
+ }
+ // 連符なので長さは適当に入れる
+ var note = new Note(scale.Scale, scale.ScaleNumber, LengthType.Whole, false);
+ components.Add(note);
+ }
+ }
+
+ // ')'でない場合、エラーを出力。
+ if (!_tokens.TryPeek(out current) || current.Type is not TokenType.RightParentheses)
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundRightParentheses, _tokens.PrevToken));
+ }
+
+ // ')'を破棄
+ _ = _tokens.Dequeue();
+
+ if (!lengthResult.TryGetValue(out var length))
+ {
+ return new(null, lengthResult.Error);
+ }
+ var tuplet = new Tuplet(components, length.LengthType, length.IsDotted);
+ return new(tuplet, null);
+ }
+
+ ///
+ /// 音符を解析する
+ ///
+ /// 解析結果
+ private ParseResult ParseNote()
+ {
+ // 音程を解析する
+ var scaleResult = ParseScale();
+ if (!scaleResult.TryGetValue(out var scale))
+ {
+ return new(null, scaleResult.Error);
+ }
+ // ','かを判定する
+ if (!_tokens.TryDequeue(out var current) || current.Type is not TokenType.Comma)
+ {
+ return new(null, new Error(SMSCReadErrorType.NotFoundComma, _tokens.PrevToken));
+ }
+ // 長さを解析する
+ var lengthResult = ParseLength();
+ if (!lengthResult.TryGetValue(out var length))
+ {
+ return new(null, lengthResult.Error);
+ }
+ var note = new Note(scale.Scale, scale.ScaleNumber, length.LengthType, length.IsDotted);
+ return new(note, null);
+ }
+
+ ///
+ /// 休符を解析する
+ ///
+ ///
+ private ParseResult ParseRest()
+ {
+ // 'rest'かを判定する
+ if (!_tokens.TryDequeue(out var current) || current.Type is not TokenType.Rest)
+ {
+ return new(null, new(SMSCReadErrorType.UndefinedStatement, _tokens.PrevToken));
+ }
+ // ','かを判定する
+ if (!_tokens.TryDequeue(out current) || current.Type is not TokenType.Comma)
+ {
+ return new(null, new(SMSCReadErrorType.NotFoundComma, _tokens.PrevToken));
+ }
+ // 長さを解析する
+ var lengthResult = ParseLength();
+ if (!lengthResult.TryGetValue(out var length))
+ {
+ return new(null, lengthResult.Error);
+ }
+ var rest = new Rest(length.LengthType, length.IsDotted);
+ return new(rest, null);
+ }
+
+ ///
+ /// 音の高さを解析する。例) C4, C#4
+ ///
+ ///
+ private ParseResult ParseScale()
+ {
+ // 最低でもトークンが2つ取れないと解析できないので、ここで判定する。
+ if (!_tokens.TryDequeue(out var current) || !_tokens.TryDequeue(out var next))
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, _tokens.PrevToken));
+ }
+
+ Scale? scaleNullable;
+ // 音程はアルファベットで書かれている
+ if (current.Type is not TokenType.Alphabet)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, current));
+ }
+
+ int scaleNumber;
+ // 次のトークンが'#'の場合は半音上の音程として解析する
+ if (next.Type is TokenType.Sharp)
+ {
+ if (!_tokens.TryDequeue(out var nextNext))
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, next));
+ }
+ if (nextNext.Type is not TokenType.Number)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, nextNext));
+ }
+
+ scaleNullable = current.Literal switch
+ {
+ "A" or "a" => Scale.ASharp,
+ "C" or "c" => Scale.CSharp,
+ "D" or "d" => Scale.DSharp,
+ "F" or "f" => Scale.FSharp,
+ "G" or "g" => Scale.GSharp,
+ _ => null,
+ };
+
+ if (scaleNullable is null)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, current));
+ }
+
+ scaleNumber = int.Parse(nextNext.Literal);
+
+ var scaleResult = new ScaleResult(scaleNullable.Value, scaleNumber);
+ return new(scaleResult, null);
+ }
+ // 次のトークンが数字の場合はナチュラルな音程
+ else if (next.Type is TokenType.Number)
+ {
+ scaleNumber = int.Parse(next.Literal);
+ scaleNullable = current.Literal switch
+ {
+ "A" or "a" => Scale.A,
+ "B" or "b" => Scale.B,
+ "C" or "c" => Scale.C,
+ "D" or "d" => Scale.D,
+ "E" or "e" => Scale.E,
+ "F" or "f" => Scale.F,
+ "G" or "g" => Scale.G,
+ _ => null,
+ };
+
+ if (scaleNullable is null)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidScale, current));
+ }
+
+ var scaleResult = new ScaleResult(scaleNullable.Value, scaleNumber);
+
+ if (ScaleHertzDictionary.IsValidScale(scaleResult.Scale, scaleResult.ScaleNumber))
+ {
+ return new(scaleResult, null);
+ }
+ }
+ // 上記条件に当てはまらない場合はエラー
+ return new(null, new(SMSCReadErrorType.InvalidScale, current));
+ }
+
+ ///
+ /// 長さを解析する
+ ///
+ /// 解析結果
+ private ParseResult ParseLength()
+ {
+ LengthType? lengthTypeNullable;
+ var isDotted = false;
+
+ if (!_tokens.TryDequeue(out var current))
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidLength, _tokens.PrevToken));
+ }
+
+ // 数字かを判定する
+ if (current.Type is not TokenType.Number)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidLength, current));
+ }
+
+ lengthTypeNullable = int.Parse(current.Literal) switch
+ {
+ 1 => LengthType.Whole,
+ 2 => LengthType.Half,
+ 4 => LengthType.Quarter,
+ 8 => LengthType.Eighth,
+ 16 => LengthType.Sixteenth,
+ 32 => LengthType.ThirtySecond,
+ 64 => LengthType.SixtyFourth,
+ _ => null,
+ };
+
+ // 次のトークンが'.'だった場合は付点音符とする。
+ if (_tokens.TryPeek(out current) && current.Type is TokenType.Dot)
+ {
+ // '.'を破棄
+ _ = _tokens.Dequeue();
+ isDotted = true;
+ }
+
+ // 不正な長さの場合
+ if (lengthTypeNullable is null)
+ {
+ return new(null, new Error(SMSCReadErrorType.InvalidLength, _tokens.PrevToken));
+ }
+
+ var length = new LengthResult(lengthTypeNullable.Value, isDotted);
+ return new(length, null);
+ }
+
+ private static bool IsEndOfStatement(Token token)
+ {
+ return token.Type is TokenType.LineBreak or TokenType.Semicolon;
+ }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/SMSC.bnf b/src/SoundMaker/ScoreData/SMSC/SMSC.bnf
new file mode 100644
index 0000000..cb432d1
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/SMSC.bnf
@@ -0,0 +1,35 @@
+# 音階を表すアルファベット('C4'の'C'の部分) - Scale symbol (the 'C' part of 'C4')
+scaleSymbol ::= A|B|C|D|E|F|G
+
+# 音階の数字('C4'の'4'の部分) - Scale number (the '4' part of 'C4')
+scaleNumber ::= 0|1|2|3|4|5|6|7|8
+
+# 音の長さの数字(全音符: 1, 16分音符: 16) - Length number (whole note: 1, sixteenth note: 16)
+lengthCount ::= 1|2|4|8|16|32|64
+
+# 長さを表す(16.など) - Length representation (16., etc.)
+length ::= '.'?
+
+# 音階(C#4など) - Scale (C#4, etc.)
+scale ::= '#'?
+
+# 音符 - Note
+note ::= ','
+
+# 休符 - Rest
+rest ::= 'rest' ','
+
+# タイの表記方法 - Tie notation
+tie ::= 'tie' '(' ',' |'rest' {',' |'rest'}* ')'
+
+# 連符の表記方法 - Tuplet notation
+tuplet ::= 'tup' '(' ',' |'rest'| {',' |'rest'|}* ')'
+
+# 文末 - End of statement
+endOfStatement ::= ';'|\n|\r|\r\n
+
+# 文の定義 - Sentence definition
+statement ::= { ||| }?
+
+# SMSC(SoundMaker SCore)データ - SMSC(SoundMaker SCore) data
+smsc ::= *
diff --git a/src/SoundMaker/ScoreData/SMSC/SMSCFormat.cs b/src/SoundMaker/ScoreData/SMSC/SMSCFormat.cs
new file mode 100644
index 0000000..dd2b8e6
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/SMSCFormat.cs
@@ -0,0 +1,33 @@
+using SoundMaker.Sounds.Score;
+
+namespace SoundMaker.ScoreData.SMSC;
+///
+/// The SMSC (SoundMaker SCore) Format
+///
+public static class SMSCFormat
+{
+ ///
+ /// Reads SMSC data.
+ /// SMSCデータを読み込む。
+ ///
+ ///
+ ///
+ public static SMSCReadResult Read(string data)
+ {
+ var lexer = new Lexer(data);
+ var result = new Parser(lexer.ReadAll()).Parse();
+ return result;
+ }
+
+ ///
+ /// Outputs SMSC data.
+ /// SMSCデータを出力する。
+ ///
+ /// Sound components to write. 書き込むサウンドコンポーネント
+ /// SMSC data. SMSCデータ
+ public static string Serialize(IEnumerable components)
+ {
+ return SMSCSerializer.Serialize(components);
+ }
+}
+
diff --git a/src/SoundMaker/ScoreData/SMSC/SMSCReadErrorType.cs b/src/SoundMaker/ScoreData/SMSC/SMSCReadErrorType.cs
new file mode 100644
index 0000000..e42b0d4
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/SMSCReadErrorType.cs
@@ -0,0 +1,18 @@
+namespace SoundMaker.ScoreData.SMSC;
+
+///
+/// Errors of reading SMSC data
+/// SMSCデータ読み込み時のエラー
+///
+public enum SMSCReadErrorType
+{
+ // OOが無い
+ NotFoundComma,
+ NotFoundLeftParentheses,
+ NotFoundRightParentheses,
+ // 不正
+ InvalidLength,
+ InvalidScale,
+ // 未定義の文
+ UndefinedStatement,
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/SMSCReadResult.cs b/src/SoundMaker/ScoreData/SMSC/SMSCReadResult.cs
new file mode 100644
index 0000000..8885fe4
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/SMSCReadResult.cs
@@ -0,0 +1,74 @@
+using SoundMaker.Sounds.Score;
+
+namespace SoundMaker.ScoreData.SMSC;
+
+///
+/// Result of reading SMSC data
+/// SMSCデータの読み取り結果
+///
+public class SMSCReadResult
+{
+ private readonly IReadOnlyList _value;
+
+ private SMSCReadResult(IReadOnlyList value, IReadOnlyList errors)
+ {
+ _value = value;
+ Errors = errors;
+ }
+
+ internal static SMSCReadResult Success(IReadOnlyList value)
+ {
+ return new SMSCReadResult(value, Array.Empty());
+ }
+
+ internal static SMSCReadResult Failure(IReadOnlyList errors)
+ {
+ return new SMSCReadResult(Array.Empty(), errors);
+ }
+
+ ///
+ /// Errors when reading SMSC.
+ /// SMSCを読み込んだ際のエラー
+ ///
+ public IReadOnlyList Errors { get; }
+
+ ///
+ /// Whether the reading was successful.
+ /// 読み込みに成功したか
+ ///
+ public bool IsSuccess => Errors.Count is 0;
+
+ ///
+ /// Returns whether the reading was successful and, if successful, returns the result. If it fails, an empty array will be in value.
+ /// 読み込みに成功したかを返し、成功した場合は結果を返す。失敗時は空の配列がvalueに入る。
+ ///
+ /// On success: result, On failure: empty array
+ /// On success: true, On failure: false
+ public bool TryGetValue(out IReadOnlyList value)
+ {
+ if (IsSuccess)
+ {
+ value = _value;
+ return true;
+ }
+
+ value = Array.Empty();
+ return false;
+ }
+
+ ///
+ /// Returns the result assuming the reading was successful. If it fails, an empty array is returned.
+ /// 読み込みに成功した前提で結果を返す。失敗時は空の配列が戻る。
+ ///
+ /// On success: result, On failure: empty array
+ public IReadOnlyList Unwrap()
+ {
+ if (!IsSuccess)
+ {
+ return Array.Empty();
+ }
+
+ return _value;
+ }
+
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/SMSCSerializer.cs b/src/SoundMaker/ScoreData/SMSC/SMSCSerializer.cs
new file mode 100644
index 0000000..4492671
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/SMSCSerializer.cs
@@ -0,0 +1,133 @@
+using SoundMaker.Sounds.Score;
+using System.Text;
+
+namespace SoundMaker.ScoreData.SMSC;
+internal static class SMSCSerializer
+{
+ public static string Serialize(IEnumerable components)
+ {
+ var smscBuilder = new StringBuilder();
+ foreach (var component in components)
+ {
+ var line = component switch
+ {
+ Note note => SerializeNote(note),
+ Rest rest => SerializeRest(rest),
+ Tie tie => SerializeTie(tie),
+ Tuplet tuplet => SerializeTuplet(tuplet),
+ _ => "",
+ };
+ _ = smscBuilder.Append(line).Append('\n');
+ }
+ return smscBuilder.ToString();
+ }
+
+ private static string SerializeNote(Note note)
+ {
+ var scale = SerializeScale(note.Scale, note.ScaleNumber);
+ var length = SerializeLength(note.Length, note.IsDotted);
+ return new StringBuilder(scale).Append(',').Append(length).ToString();
+ }
+
+ private static string SerializeRest(Rest rest)
+ {
+ var length = SerializeLength(rest.Length, rest.IsDotted);
+ return new StringBuilder("rest").Append(',').Append(length).ToString();
+ }
+
+ private static string SerializeTie(Tie tie)
+ {
+ var builder = new StringBuilder("tie(");
+ var scale = SerializeScale(tie.BaseNote.Scale, tie.BaseNote.ScaleNumber);
+ var length = SerializeLength(tie.BaseNote.Length, tie.BaseNote.IsDotted);
+ _ = builder.Append(scale).Append(',').Append(length);
+ foreach (var note in tie.AdditionalNotes)
+ {
+ length = SerializeLength(note.Length, note.IsDotted);
+ _ = builder.Append(',').Append(length);
+ }
+ _ = builder.Append(')');
+ return builder.ToString();
+ }
+
+ private static string SerializeTuplet(Tuplet tuplet)
+ {
+ var builder = new StringBuilder("tup(");
+ var length = SerializeLength(tuplet.Length, tuplet.IsDotted);
+ _ = builder.Append(length);
+ foreach (var component in tuplet.TupletComponents)
+ {
+ if (component is Tuplet tup)
+ {
+ var tupStr = SerializeTuplet(tup);
+ _ = builder.Append(',').Append(tupStr);
+ }
+ else if (component is Tie tie)
+ {
+ var tieStr = SerializeTie(tie);
+ _ = builder.Append(',').Append(tieStr);
+ }
+ else if (component is Rest rest)
+ {
+ _ = builder.Append(',').Append("rest");
+ if (rest.IsDotted)
+ {
+ _ = builder.Append('.');
+ }
+ }
+ else if (component is Note note)
+ {
+ var scale = SerializeScale(note.Scale, note.ScaleNumber);
+ _ = builder.Append(',').Append(scale);
+ if (note.IsDotted)
+ {
+ _ = builder.Append('.');
+ }
+ }
+ }
+ _ = builder.Append(')');
+ return builder.ToString();
+ }
+
+ private static string SerializeScale(Scale scaleType, int scaleNumber)
+ {
+ var scale = scaleType switch
+ {
+ Scale.A => "A",
+ Scale.ASharp => "A#",
+ Scale.B => "B",
+ Scale.C => "C",
+ Scale.CSharp => "C#",
+ Scale.D => "D",
+ Scale.DSharp => "D#",
+ Scale.E => "E",
+ Scale.F => "F",
+ Scale.FSharp => "F#",
+ Scale.G => "G",
+ Scale.GSharp => "G#",
+ _ => "A"
+ };
+ return new StringBuilder(scale).Append(scaleNumber.ToString()).ToString();
+ }
+
+ private static string SerializeLength(LengthType lengthType, bool isDotted)
+ {
+ var length = lengthType switch
+ {
+ LengthType.Whole => "1",
+ LengthType.Half => "2",
+ LengthType.Quarter => "4",
+ LengthType.Eighth => "8",
+ LengthType.Sixteenth => "16",
+ LengthType.ThirtySecond => "32",
+ LengthType.SixtyFourth => "64",
+ _ => "1"
+ };
+ var builder = new StringBuilder(length);
+ if (isDotted)
+ {
+ _ = builder.Append('.');
+ }
+ return builder.ToString();
+ }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/Token.cs b/src/SoundMaker/ScoreData/SMSC/Token.cs
new file mode 100644
index 0000000..6e48aef
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/Token.cs
@@ -0,0 +1,16 @@
+namespace SoundMaker.ScoreData.SMSC;
+internal record class Token
+{
+ public Token(TokenType type, string literal, int lineNumber)
+ {
+ Type = type;
+ Literal = literal;
+ LineNumber = lineNumber;
+ }
+
+ public TokenType Type { get; }
+
+ public string Literal { get; }
+
+ public int LineNumber { get; }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/TokenQueue.cs b/src/SoundMaker/ScoreData/SMSC/TokenQueue.cs
new file mode 100644
index 0000000..066af4a
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/TokenQueue.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SoundMaker.ScoreData.SMSC;
+internal class TokenQueue
+{
+ private readonly Queue _tokens;
+
+ public TokenQueue(IEnumerable tokens)
+ {
+ _tokens = new(tokens);
+ }
+
+ public Token? PrevToken { get; private set; }
+
+ public int Count => _tokens.Count;
+
+ public bool TryDequeue([MaybeNullWhen(false)] out Token token)
+ {
+ if (_tokens.TryDequeue(out token))
+ {
+ PrevToken = token;
+ return true;
+ }
+ return false;
+ }
+
+ public Token Dequeue()
+ {
+ return _tokens.Dequeue();
+ }
+
+ public bool TryPeek([MaybeNullWhen(false)] out Token token)
+ {
+ return _tokens.TryPeek(out token);
+ }
+}
diff --git a/src/SoundMaker/ScoreData/SMSC/TokenType.cs b/src/SoundMaker/ScoreData/SMSC/TokenType.cs
new file mode 100644
index 0000000..c015ec8
--- /dev/null
+++ b/src/SoundMaker/ScoreData/SMSC/TokenType.cs
@@ -0,0 +1,22 @@
+namespace SoundMaker.ScoreData.SMSC;
+internal enum TokenType
+{
+ // Symbols
+ Dot,
+ Sharp,
+ LeftParentheses,
+ RightParentheses,
+ Semicolon,
+ Comma,
+ // Characters
+ Alphabet,
+ Number,
+ // keywords
+ Tie,
+ Tuplet,
+ Rest,
+ // line break
+ LineBreak,
+ // ?
+ Unknown,
+}
diff --git a/src/SoundMaker/Sounds/Score/BasicSoundComponentBase.cs b/src/SoundMaker/Sounds/Score/BasicSoundComponentBase.cs
index a9686dc..9fbbb32 100644
--- a/src/SoundMaker/Sounds/Score/BasicSoundComponentBase.cs
+++ b/src/SoundMaker/Sounds/Score/BasicSoundComponentBase.cs
@@ -33,6 +33,6 @@ public BasicSoundComponentBase(LengthType length, bool isDotted)
public int GetWaveArrayLength(SoundFormat format, int tempo)
{
- return SoundWaveLengthCaluclator.Caluclate(format, tempo, Length, IsDotted);
+ return SoundWaveLengthCalclator.Calclate(format, tempo, Length, IsDotted);
}
}
diff --git a/src/SoundMaker/Sounds/Score/Note.cs b/src/SoundMaker/Sounds/Score/Note.cs
index 928d0c1..4313a22 100644
--- a/src/SoundMaker/Sounds/Score/Note.cs
+++ b/src/SoundMaker/Sounds/Score/Note.cs
@@ -6,43 +6,6 @@ namespace SoundMaker.Sounds.Score;
///
public class Note : BasicSoundComponentBase
{
- private static readonly IReadOnlyDictionary<(Scale, int), double> _hertz = new Dictionary<(Scale, int), double>()
- {
- // A0 ~
- {(Scale.A, 0), 27.500 },
- {(Scale.ASharp, 0), 29.135 },
- {(Scale.B, 0), 30.868 },
- // C1 ~ C2 ~ C3 ~ C4 ~
- {(Scale.C, 1), 32.703 }, {(Scale.C, 2), 65.406 }, {(Scale.C, 3), 130.813 }, {(Scale.C, 4), 261.626 },
- {(Scale.CSharp, 1), 34.648 }, {(Scale.CSharp, 2), 69.296 }, {(Scale.CSharp, 3), 138.591 }, {(Scale.CSharp, 4), 277.183 },
- {(Scale.D, 1), 36.708 }, {(Scale.D, 2), 73.416 }, {(Scale.D, 3), 146.832 }, {(Scale.D, 4), 293.665 },
- {(Scale.DSharp, 1), 38.891 }, {(Scale.DSharp, 2), 77.782 }, {(Scale.DSharp, 3), 155.563 }, {(Scale.DSharp, 4), 311.127 },
- {(Scale.E, 1), 41.203 }, {(Scale.E, 2), 82.407 }, {(Scale.E, 3), 164.814 }, {(Scale.E, 4), 329.628 },
- {(Scale.F, 1), 43.654 }, {(Scale.F, 2), 87.307 }, {(Scale.F, 3), 174.614 }, {(Scale.F, 4), 349.228 },
- {(Scale.FSharp, 1), 46.249 }, {(Scale.FSharp, 2), 92.499 }, {(Scale.FSharp, 3), 184.997 }, {(Scale.FSharp, 4), 369.994 },
- {(Scale.G, 1), 48.999 }, {(Scale.G, 2), 97.999 }, {(Scale.G, 3), 195.998 }, {(Scale.G, 4), 391.995 },
- {(Scale.GSharp, 1), 51.913 }, {(Scale.GSharp, 2), 103.826 }, {(Scale.GSharp, 3), 207.652 }, {(Scale.GSharp, 4), 415.305 },
- {(Scale.A, 1), 55.000 }, {(Scale.A, 2), 110.000 }, {(Scale.A, 3), 220.000 }, {(Scale.A, 4), 440.000 },
- {(Scale.ASharp, 1), 58.270 }, {(Scale.ASharp, 2), 116.541 }, {(Scale.ASharp, 3), 233.082 }, {(Scale.ASharp, 4), 466.164 },
- {(Scale.B, 1), 61.735 }, {(Scale.B, 2), 123.471 }, {(Scale.B, 3), 246.942 }, {(Scale.B, 4), 493.883 },
- // C5 ~ C6 ~ C7 ~
- {(Scale.C, 5), 523.251 }, {(Scale.C, 6), 1046.502 }, {(Scale.C, 7), 2093.005 },
- {(Scale.CSharp, 5), 554.365 }, {(Scale.CSharp, 6), 1108.731 }, {(Scale.CSharp, 7), 2217.461 },
- {(Scale.D, 5), 587.330 }, {(Scale.D, 6), 1174.659 }, {(Scale.D, 7), 2349.318 },
- {(Scale.DSharp, 5), 622.254 }, {(Scale.DSharp, 6), 1244.508 }, {(Scale.DSharp, 7), 2489.016 },
- {(Scale.E, 5), 659.255 }, {(Scale.E, 6), 1318.510 }, {(Scale.E, 7), 2637.020 },
- {(Scale.F, 5), 698.456 }, {(Scale.F, 6), 1396.913 }, {(Scale.F, 7), 2793.826 },
- {(Scale.FSharp, 5), 739.989 }, {(Scale.FSharp, 6), 1479.978 }, {(Scale.FSharp, 7), 2959.955 },
- {(Scale.G, 5), 783.991 }, {(Scale.G, 6), 1567.982 }, {(Scale.G, 7), 3135.963 },
- {(Scale.GSharp, 5), 830.609 }, {(Scale.GSharp, 6), 1661.219 }, {(Scale.GSharp, 7), 3322.438 },
- {(Scale.A, 5), 880.000 }, {(Scale.A, 6), 1760.000 }, {(Scale.A, 7), 3520.000 },
- {(Scale.ASharp, 5), 932.328 }, {(Scale.ASharp, 6), 1864.655 }, {(Scale.ASharp, 7), 3729.310 },
- {(Scale.B, 5), 987.767 }, {(Scale.B, 6), 1975.533 }, {(Scale.B, 7), 3951.066 },
- // C8
- {(Scale.C, 8), 4186.009 },
- };
-
-
///
/// constructor コンストラクタ
///
@@ -51,11 +14,11 @@ public class Note : BasicSoundComponentBase
/// length (ex. "quarter" note) 長さ(音楽的な、「四分」音符、「全」休符のような長さを表す。)
/// is note/rest dotted. 付点かを表す論理型
/// Scale and scale number must be only the range of sound that the piano can produce.
- public Note(Scale scale, int scaleNumber, LengthType length, bool isDotted = false)
+ public Note(Scale scale, int scaleNumber, LengthType length, bool isDotted = false)
: base(length, isDotted)
{
CheckArgument(scale, scaleNumber);
- Hertz += _hertz[(scale, scaleNumber)];
+ Hertz += ScaleHertzDictionary.GetHertz(scale, scaleNumber);
Scale = scale;
ScaleNumber = scaleNumber;
}
@@ -69,26 +32,11 @@ public Note(LengthType length, bool isDotted = false) : base(length, isDotted)
{
var scale = Scale.A;
var scaleNumber = 4;
- Hertz += AHertz[scaleNumber];
+ Hertz += ScaleHertzDictionary.GetHertz(scale, scaleNumber);
Scale = scale;
ScaleNumber = scaleNumber;
}
- ///
- /// 「ラ」の周波数
- ///
- private double[] AHertz { get; } = new double[]
- {
- // A0からA
- 27.5d,
- 55.0d,
- 110.0d,
- 220.0d,
- 440.0d,
- 880.0d,
- 1760.0d,
- 3520.0d
- };
///
/// scale of the note. 音の高さ
///
@@ -125,18 +73,10 @@ public int Volume
}
}
- private void CheckArgument(Scale scale, int scaleNumber)
+ private static void CheckArgument(Scale scale, int scaleNumber)
{
var message = "'scale' and 'scaleNumber' must be only the range of sound that the piano can produce.";
- if (scaleNumber is >= 9 or <= (-1))
- {
- throw new ArgumentException(message);
- }
- if (scale != Scale.A && scale != Scale.B && scale != Scale.ASharp && scaleNumber == 0)
- {
- throw new ArgumentException(message);
- }
- if (scale != Scale.C && scaleNumber == 8)
+ if (!ScaleHertzDictionary.IsValidScale(scale, scaleNumber))
{
throw new ArgumentException(message);
}
diff --git a/src/SoundMaker/Sounds/Score/ScaleHertzDictionary.cs b/src/SoundMaker/Sounds/Score/ScaleHertzDictionary.cs
new file mode 100644
index 0000000..7e9b072
--- /dev/null
+++ b/src/SoundMaker/Sounds/Score/ScaleHertzDictionary.cs
@@ -0,0 +1,49 @@
+namespace SoundMaker.Sounds.Score;
+internal static class ScaleHertzDictionary
+{
+ private static readonly IReadOnlyDictionary<(Scale, int), double> _hertz = new Dictionary<(Scale, int), double>()
+ {
+ // A0 ~
+ {(Scale.A, 0), 27.500 },
+ {(Scale.ASharp, 0), 29.135 },
+ {(Scale.B, 0), 30.868 },
+ // C1 ~ C2 ~ C3 ~ C4 ~
+ {(Scale.C, 1), 32.703 }, {(Scale.C, 2), 65.406 }, {(Scale.C, 3), 130.813 }, {(Scale.C, 4), 261.626 },
+ {(Scale.CSharp, 1), 34.648 }, {(Scale.CSharp, 2), 69.296 }, {(Scale.CSharp, 3), 138.591 }, {(Scale.CSharp, 4), 277.183 },
+ {(Scale.D, 1), 36.708 }, {(Scale.D, 2), 73.416 }, {(Scale.D, 3), 146.832 }, {(Scale.D, 4), 293.665 },
+ {(Scale.DSharp, 1), 38.891 }, {(Scale.DSharp, 2), 77.782 }, {(Scale.DSharp, 3), 155.563 }, {(Scale.DSharp, 4), 311.127 },
+ {(Scale.E, 1), 41.203 }, {(Scale.E, 2), 82.407 }, {(Scale.E, 3), 164.814 }, {(Scale.E, 4), 329.628 },
+ {(Scale.F, 1), 43.654 }, {(Scale.F, 2), 87.307 }, {(Scale.F, 3), 174.614 }, {(Scale.F, 4), 349.228 },
+ {(Scale.FSharp, 1), 46.249 }, {(Scale.FSharp, 2), 92.499 }, {(Scale.FSharp, 3), 184.997 }, {(Scale.FSharp, 4), 369.994 },
+ {(Scale.G, 1), 48.999 }, {(Scale.G, 2), 97.999 }, {(Scale.G, 3), 195.998 }, {(Scale.G, 4), 391.995 },
+ {(Scale.GSharp, 1), 51.913 }, {(Scale.GSharp, 2), 103.826 }, {(Scale.GSharp, 3), 207.652 }, {(Scale.GSharp, 4), 415.305 },
+ {(Scale.A, 1), 55.000 }, {(Scale.A, 2), 110.000 }, {(Scale.A, 3), 220.000 }, {(Scale.A, 4), 440.000 },
+ {(Scale.ASharp, 1), 58.270 }, {(Scale.ASharp, 2), 116.541 }, {(Scale.ASharp, 3), 233.082 }, {(Scale.ASharp, 4), 466.164 },
+ {(Scale.B, 1), 61.735 }, {(Scale.B, 2), 123.471 }, {(Scale.B, 3), 246.942 }, {(Scale.B, 4), 493.883 },
+ // C5 ~ C6 ~ C7 ~
+ {(Scale.C, 5), 523.251 }, {(Scale.C, 6), 1046.502 }, {(Scale.C, 7), 2093.005 },
+ {(Scale.CSharp, 5), 554.365 }, {(Scale.CSharp, 6), 1108.731 }, {(Scale.CSharp, 7), 2217.461 },
+ {(Scale.D, 5), 587.330 }, {(Scale.D, 6), 1174.659 }, {(Scale.D, 7), 2349.318 },
+ {(Scale.DSharp, 5), 622.254 }, {(Scale.DSharp, 6), 1244.508 }, {(Scale.DSharp, 7), 2489.016 },
+ {(Scale.E, 5), 659.255 }, {(Scale.E, 6), 1318.510 }, {(Scale.E, 7), 2637.020 },
+ {(Scale.F, 5), 698.456 }, {(Scale.F, 6), 1396.913 }, {(Scale.F, 7), 2793.826 },
+ {(Scale.FSharp, 5), 739.989 }, {(Scale.FSharp, 6), 1479.978 }, {(Scale.FSharp, 7), 2959.955 },
+ {(Scale.G, 5), 783.991 }, {(Scale.G, 6), 1567.982 }, {(Scale.G, 7), 3135.963 },
+ {(Scale.GSharp, 5), 830.609 }, {(Scale.GSharp, 6), 1661.219 }, {(Scale.GSharp, 7), 3322.438 },
+ {(Scale.A, 5), 880.000 }, {(Scale.A, 6), 1760.000 }, {(Scale.A, 7), 3520.000 },
+ {(Scale.ASharp, 5), 932.328 }, {(Scale.ASharp, 6), 1864.655 }, {(Scale.ASharp, 7), 3729.310 },
+ {(Scale.B, 5), 987.767 }, {(Scale.B, 6), 1975.533 }, {(Scale.B, 7), 3951.066 },
+ // C8
+ {(Scale.C, 8), 4186.009 },
+ };
+
+ public static double GetHertz(Scale scale, int scaleNumber)
+ {
+ return _hertz[(scale, scaleNumber)];
+ }
+
+ public static bool IsValidScale(Scale scale, int scaleNumber)
+ {
+ return _hertz.ContainsKey((scale, scaleNumber));
+ }
+}
diff --git a/src/SoundMaker/Sounds/Score/SoundWaveLengthCaluclator.cs b/src/SoundMaker/Sounds/Score/SoundWaveLengthCalclator.cs
similarity index 92%
rename from src/SoundMaker/Sounds/Score/SoundWaveLengthCaluclator.cs
rename to src/SoundMaker/Sounds/Score/SoundWaveLengthCalclator.cs
index 647619c..b07c16c 100644
--- a/src/SoundMaker/Sounds/Score/SoundWaveLengthCaluclator.cs
+++ b/src/SoundMaker/Sounds/Score/SoundWaveLengthCalclator.cs
@@ -2,7 +2,7 @@
///
/// 音の配列の長さを計算するクラス
///
-internal static class SoundWaveLengthCaluclator
+internal static class SoundWaveLengthCalclator
{
///
/// メソッド。
@@ -13,7 +13,7 @@ internal static class SoundWaveLengthCaluclator
/// 付点の場合はTrueに設定する
/// 音の配列の長さ : int
/// Tempo must be non-negative and greater than 0.
- public static int Caluclate(SoundFormat format, int tempo, LengthType length, bool isDotted)
+ public static int Calclate(SoundFormat format, int tempo, LengthType length, bool isDotted)
{
if (tempo <= 0)
{
diff --git a/src/SoundMaker/Sounds/Score/Tie.cs b/src/SoundMaker/Sounds/Score/Tie.cs
index d583b64..467629e 100644
--- a/src/SoundMaker/Sounds/Score/Tie.cs
+++ b/src/SoundMaker/Sounds/Score/Tie.cs
@@ -17,7 +17,7 @@ public Tie(Note baseNote, LengthType additionalLength, bool additionalIsDotted =
BaseNote = baseNote;
AdditionalNotes = new List()
{
- new Note(baseNote.Scale, baseNote.ScaleNumber, additionalLength, additionalIsDotted)
+ new(baseNote.Scale, baseNote.ScaleNumber, additionalLength, additionalIsDotted)
};
}
@@ -63,7 +63,7 @@ public int GetWaveArrayLength(SoundFormat format, int tempo)
var length = BaseNote.GetWaveArrayLength(format, tempo);
foreach (var note in AdditionalNotes)
{
- length += SoundWaveLengthCaluclator.Caluclate(format, tempo, note.Length, note.IsDotted);
+ length += SoundWaveLengthCalclator.Calclate(format, tempo, note.Length, note.IsDotted);
}
return length;
}
diff --git a/src/SoundMaker/Sounds/Score/Tuplet.cs b/src/SoundMaker/Sounds/Score/Tuplet.cs
index 0d59540..7dd1b18 100644
--- a/src/SoundMaker/Sounds/Score/Tuplet.cs
+++ b/src/SoundMaker/Sounds/Score/Tuplet.cs
@@ -22,7 +22,7 @@ public Tuplet(IReadOnlyList tupletComponents, LengthType length
///
/// components to be tuplet. 連符にする基本の音のリスト
///
- private IReadOnlyList TupletComponents { get; }
+ internal IReadOnlyList TupletComponents { get; }
///
/// get the component at index. index番目の連符の音を取得する。
@@ -51,7 +51,7 @@ public Tuplet(IReadOnlyList tupletComponents, LengthType length
public int GetWaveArrayLength(SoundFormat format, int tempo)
{
- return SoundWaveLengthCaluclator.Caluclate(format, tempo, Length, IsDotted);
+ return SoundWaveLengthCalclator.Calclate(format, tempo, Length, IsDotted);
}
public ushort[] GenerateWave(SoundFormat format, int tempo, int length, WaveTypeBase waveType)
diff --git a/src/SoundMaker/Sounds/SoundChannels/SoundChannelBase.cs b/src/SoundMaker/Sounds/SoundChannels/SoundChannelBase.cs
index decfca4..5610f1d 100644
--- a/src/SoundMaker/Sounds/SoundChannels/SoundChannelBase.cs
+++ b/src/SoundMaker/Sounds/SoundChannels/SoundChannelBase.cs
@@ -55,7 +55,7 @@ public SoundChannelBase(int tempo, SoundFormat format, PanType panType)
///
/// サウンドコンポーネントのリスト
///
- protected List SoundComponents { get; } = new List();
+ protected List SoundComponents { get; private set; } = new List();
public SoundFormat Format { get; }
@@ -107,6 +107,16 @@ public void RemoveAt(int index)
_ = SoundComponents.Remove(component);
}
+ ///
+ /// Import sound components. サウンドコンポーネントをインポートする。
+ ///
+ /// Sound components. サウンドコンポーネント
+ public void Import(IEnumerable components)
+ {
+ SoundComponents = new List(components);
+ WaveArrayLength = components.Sum(component => component.GetWaveArrayLength(Format, Tempo));
+ }
+
public abstract ushort[] GenerateWave();
public IEnumerator GetEnumerator()
diff --git a/test/Experiment.cs b/test/Experiment.cs
deleted file mode 100644
index b814fa2..0000000
--- a/test/Experiment.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using SoundMaker;
-using SoundMaker.Sounds;
-using SoundMaker.Sounds.Score;
-using SoundMaker.Sounds.SoundChannels;
-using SoundMaker.WaveFile;
-using Xunit.Abstractions;
-
-namespace SoundMakerTests;
-public class Experiment
-{
- public Experiment(ITestOutputHelper output)
- {
- Output = output;
- }
-
- private ITestOutputHelper Output { get; }
-
- [Fact(DisplayName = "README.md̃Tv")]
- public void TestReflectedVolume()
- {
- Main();
- }
-
- private void Main()
- {
- // Create a sound format.
- var builder = FormatBuilder.Create()
- .WithFrequency(48000)
- .WithBitDepth(16)
- .WithChannelCount(2);
-
- var soundFormat = builder.ToSoundFormat();
- var wave = MakeStereoWave(soundFormat);
-
- // Write to a file.
- var sound = new SoundWaveChunk(wave.GetBytes(soundFormat.BitRate));
- var waveFileFormat = builder.ToFormatChunk();
- var writer = new WaveWriter(waveFileFormat, sound);
- var filePath = "sample.wav";
- writer.Write(filePath);
- }
-
- private StereoWave MakeStereoWave(SoundFormat format)
- {
- // The number of quarter notes per minute
- var tempo = 100;
- // First, you need to create sound channels.
- // Currently, it supports square wave, triangle wave, pseudo-triangle wave, and low-bit noise.
- var rightChannel = new SquareSoundChannel(tempo, format, SquareWaveRatio.Point25, PanType.Right)
- {
- // Add objects of classes that implement ISoundComponent to the channel.
- // Currently, you can use normal notes, rests, ties, and tuplets.
- new Note(Scale.C, 5, LengthType.Eighth, isDotted: true),
- new Tie(new Note(Scale.D, 5, LengthType.Eighth), LengthType.Eighth),
- new Tuplet(GetComponents(), LengthType.Quarter)
- };
- var rightChannel2 = new SquareSoundChannel(tempo, format, SquareWaveRatio.Point125, PanType.Right)
- {
- new Note(Scale.C, 4, LengthType.Eighth, isDotted: true),
- new Note(Scale.D, 4, LengthType.Quarter),
- new Rest(LengthType.Quarter)
- };
- var leftChannel = new TriangleSoundChannel(tempo, format, PanType.Left)
- {
- new Note(Scale.C, 3, LengthType.Eighth, isDotted: true),
- new Note(Scale.D, 3, LengthType.Quarter),
- new Rest(LengthType.Quarter)
- };
-
- var channels = new List() { rightChannel, rightChannel2, leftChannel };
- // Mixing is done by the 'StereoMixer' class.
- return new StereoMixer(channels).Mix();
- }
-
- private IReadOnlyList GetComponents()
- {
- return new List()
- {
- new Note(Scale.E, 5, LengthType.Eighth),
- new Note(Scale.F, 5, LengthType.Eighth),
- new Note(Scale.G, 5, LengthType.Eighth),
- };
- }
-}
diff --git a/test/UnitTests/ScoreData/SMSC/LexerTest.cs b/test/UnitTests/ScoreData/SMSC/LexerTest.cs
new file mode 100644
index 0000000..128999b
--- /dev/null
+++ b/test/UnitTests/ScoreData/SMSC/LexerTest.cs
@@ -0,0 +1,101 @@
+using SoundMaker.ScoreData.SMSC;
+
+namespace SoundMakerTests.UnitTests.ScoreData.SMSC;
+
+public class LexerTest
+{
+ [Fact(DisplayName = "コメントは解析しないか")]
+ public void TestReadAll_Comment()
+ {
+ var data = @"// Comment";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ Assert.Empty(tokens);
+ }
+
+ [Fact(DisplayName = "通常の音符を解析できるか")]
+ public void TestReadAll_Note()
+ {
+ var data = @"// Note
+C4, 16
+";
+ var lexer = new Lexer(data);
+ var expected = new List()
+ {
+ new(TokenType.Alphabet, "C", 2),
+ new(TokenType.Number, "4", 2),
+ new(TokenType.Comma, ",", 2),
+ new(TokenType.Number, "16", 2),
+ new(TokenType.LineBreak, "\n", 2),
+ };
+ var tokens = lexer.ReadAll();
+ Assert.Equal(expected.Count, tokens.Count);
+
+ AssertToken(expected[0], tokens[0]);
+ AssertToken(expected[1], tokens[1]);
+ AssertToken(expected[2], tokens[2]);
+ AssertToken(expected[3], tokens[3]);
+ AssertToken(expected[4], tokens[4]);
+ }
+
+ [Fact(DisplayName = "付点音符を解析できるか")]
+ public void TestReadAll_DottedNote()
+ {
+ var data = @"// Note
+C4, 16.
+";
+ var lexer = new Lexer(data);
+ var expected = new List()
+ {
+ new(TokenType.Alphabet, "C", 2),
+ new(TokenType.Number, "4", 2),
+ new(TokenType.Comma, ",", 2),
+ new(TokenType.Number, "16", 2),
+ new(TokenType.Dot, ".", 2),
+ new(TokenType.LineBreak, "\n", 2),
+ };
+ var tokens = lexer.ReadAll();
+ Assert.Equal(expected.Count, tokens.Count);
+
+ AssertToken(expected[0], tokens[0]);
+ AssertToken(expected[1], tokens[1]);
+ AssertToken(expected[2], tokens[2]);
+ AssertToken(expected[3], tokens[3]);
+ AssertToken(expected[4], tokens[4]);
+ AssertToken(expected[5], tokens[5]);
+ }
+
+ [Fact(DisplayName = "半音上の音符を解析できるか")]
+ public void TestReadAll_SharpNote()
+ {
+ var data = @"// Note
+C#4, 16
+";
+ var lexer = new Lexer(data);
+ var expected = new List()
+ {
+ new(TokenType.Alphabet, "C", 2),
+ new(TokenType.Sharp, "#", 2),
+ new(TokenType.Number, "4", 2),
+ new(TokenType.Comma, ",", 2),
+ new(TokenType.Number, "16", 2),
+ new(TokenType.LineBreak, "\n", 2),
+ };
+ var tokens = lexer.ReadAll();
+ Assert.Equal(expected.Count, tokens.Count);
+
+ AssertToken(expected[0], tokens[0]);
+ AssertToken(expected[1], tokens[1]);
+ AssertToken(expected[2], tokens[2]);
+ AssertToken(expected[3], tokens[3]);
+ AssertToken(expected[4], tokens[4]);
+ AssertToken(expected[5], tokens[5]);
+ }
+
+ private void AssertToken(Token expected, Token actual)
+ {
+ Assert.Equal(expected.Type, actual.Type);
+ Assert.Equal(expected.Literal, actual.Literal);
+ Assert.Equal(expected.LineNumber, actual.LineNumber);
+ }
+}
diff --git a/test/UnitTests/ScoreData/SMSC/ParserTest.cs b/test/UnitTests/ScoreData/SMSC/ParserTest.cs
new file mode 100644
index 0000000..2a986e1
--- /dev/null
+++ b/test/UnitTests/ScoreData/SMSC/ParserTest.cs
@@ -0,0 +1,286 @@
+using SoundMaker.ScoreData.SMSC;
+using SoundMaker.Sounds.Score;
+
+namespace SoundMakerTests.UnitTests.ScoreData.SMSC;
+
+public class ParserTest
+{
+ [Fact(DisplayName = "普通の音符を解析できるか")]
+ public void TestParse_Note()
+ {
+ var data = "C4, 16";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Note(Scale.C, 4, LengthType.Sixteenth),
+ };
+
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Note)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Scale, actual.Scale);
+ Assert.Equal(expected.ScaleNumber, actual.ScaleNumber);
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(expected.IsDotted, actual.IsDotted);
+ }
+
+ [Fact(DisplayName = "付点音符を解析できるか")]
+ public void TestParse_DottedNote()
+ {
+ var data = "C4, 16.";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Note(Scale.C, 4, LengthType.Sixteenth, true),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Note)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Scale, actual.Scale);
+ Assert.Equal(expected.ScaleNumber, actual.ScaleNumber);
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(expected.IsDotted, actual.IsDotted);
+ }
+
+ [Fact(DisplayName = "半音上の音符を解析できるか")]
+ public void TestParse_SharpNote()
+ {
+ var data = "C#4, 16";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Note(Scale.CSharp, 4, LengthType.Sixteenth),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Note)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Scale, actual.Scale);
+ Assert.Equal(expected.ScaleNumber, actual.ScaleNumber);
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(expected.IsDotted, actual.IsDotted);
+ }
+
+ [Fact(DisplayName = "普通の休符を解析できるか")]
+ public void TestParse_Rest()
+ {
+ var data = "rest, 16";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Rest(LengthType.Sixteenth),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Rest)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(expected.IsDotted, actual.IsDotted);
+ }
+
+ [Fact(DisplayName = "付点休符を解析できるか")]
+ public void TestParse_DottedRest()
+ {
+ var data = "rest, 16.";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Rest(LengthType.Sixteenth, true),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Rest)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(expected.IsDotted, actual.IsDotted);
+ }
+
+ [Fact(DisplayName = "タイを解析できるか")]
+ public void TestParse_Tie()
+ {
+ var data = "tie(C#4, 16, 8.)";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Tie(new(Scale.CSharp, 4, LengthType.Sixteenth), new List()
+ {
+ new(LengthType.Eighth, true),
+ }),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Tie)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Count, actual.Count);
+ }
+
+ [Fact(DisplayName = "連符を解析できるか")]
+ public void TestParse_Tuplet()
+ {
+ var data = "tup(16, C#4, rest, E4)";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var expectedCollection = new List()
+ {
+ new Tuplet(new List()
+ {
+ new Note(Scale.CSharp, 4, LengthType.Whole),
+ new Rest(LengthType.Eighth),
+ new Note(Scale.E, 4,LengthType.Whole, true),
+ }, LengthType.Sixteenth),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Tuplet)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Count, actual.Count);
+ }
+
+ [Fact(DisplayName = "連符内のタイや連符を解析できるか")]
+ public void TestParse_TupletInTupletAndTie()
+ {
+ var data = "tup(16, tup(16, rest, rest), tie(C#4, 16, 8, 8, 8.))";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+
+ var rest = new Rest(LengthType.Eighth);
+ var expectedCollection = new List()
+ {
+ new Tuplet(new List()
+ {
+ new Tuplet(new List()
+ {
+ rest, rest
+ }, LengthType.Sixteenth),
+ new Tie(new(Scale.CSharp, 4, LengthType.Sixteenth), new List()
+ {
+ new(LengthType.Eighth),
+ new(LengthType.Eighth),
+ new(LengthType.Eighth, true),
+ }),
+ }, LengthType.Sixteenth),
+ };
+ var result = parser.Parse();
+ Assert.True(result.IsSuccess);
+ var actualCollection = result.Unwrap();
+ Assert.Equal(expectedCollection.Count, actualCollection.Count);
+
+ var expected = (Tuplet)expectedCollection[0];
+ var actual = Assert.IsType(actualCollection[0]);
+ Assert.Equal(expected.Count, actual.Count);
+ }
+
+ // ------------------エラーのテスト---------------------------
+
+ [Fact(DisplayName = "空の文でエラーが発生しないか")]
+ public void Test_EmptyStatment()
+ {
+ var data = ";;;";
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors;
+ Assert.Empty(actual);
+ }
+
+ [Theory(DisplayName = "音程解析エラーのテスト")]
+ [InlineData("C100,1\n D8,1 // A0~C8のみ許可する", 2)]
+ [InlineData("A0,1; C8,1", 0)]
+ public void Test_ScaleErrors(string data, int expectedCount)
+ {
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors.Count(err => err.Type is SMSCReadErrorType.InvalidScale);
+ Assert.Equal(expectedCount, actual);
+ }
+
+ [Theory(DisplayName = "長さ解析エラーのテスト")]
+ [InlineData("C4,1; C4,2; C4,4; C4,8; C4,16; C4,32; C4,1; C4,64", 0)]
+ [InlineData("C4,0; C4,65; C4,", 3)]
+ public void Test_LengthErrors(string data, int expectedCount)
+ {
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors.Count(err => err.Type is SMSCReadErrorType.InvalidLength);
+ Assert.Equal(expectedCount, actual);
+ }
+
+ [Theory(DisplayName = "カンマが見つからないエラーのテスト")]
+ [InlineData("C4.1; tie(C4.1)", 2)]
+ public void Test_NotFoundCommaErrors(string data, int expectedCount)
+ {
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors.Count(err => err.Type is SMSCReadErrorType.NotFoundComma);
+ Assert.Equal(expectedCount, actual);
+ }
+
+ [Theory(DisplayName = ")が見つからないエラーのテスト")]
+ [InlineData("tie(C4,1;tup(4,C4", 2)]
+ public void Test_NotFoundRightParenthesesErrors(string data, int expectedCount)
+ {
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors.Count(err => err.Type is SMSCReadErrorType.NotFoundRightParentheses);
+ Assert.Equal(expectedCount, actual);
+ }
+
+ [Theory(DisplayName = "(が見つからないエラーのテスト")]
+ [InlineData("tie)C4,1;tup.4,C4", 2)]
+ public void Test_NotFoundLeftParenthesesErrors(string data, int expectedCount)
+ {
+ var lexer = new Lexer(data);
+ var tokens = lexer.ReadAll();
+ var parser = new Parser(tokens);
+ var actual = parser.Parse().Errors.Count(err => err.Type is SMSCReadErrorType.NotFoundLeftParentheses);
+ Assert.Equal(expectedCount, actual);
+ }
+}
diff --git a/test/UnitTests/ScoreData/SMSC/SMSCSerializerTest.cs b/test/UnitTests/ScoreData/SMSC/SMSCSerializerTest.cs
new file mode 100644
index 0000000..10a3ef4
--- /dev/null
+++ b/test/UnitTests/ScoreData/SMSC/SMSCSerializerTest.cs
@@ -0,0 +1,33 @@
+using SoundMaker.ScoreData.SMSC;
+using SoundMaker.Sounds.Score;
+
+namespace SoundMakerTests.UnitTests.ScoreData.SMSC;
+public class SMSCSerializerTest
+{
+ [Fact(DisplayName = "SoundComponentが正しくSMSCデータになるか")]
+ public void TestSerialize()
+ {
+ var note = new Note(Scale.C, 4, LengthType.Whole, true);
+ var tupletNote = new Note(Scale.C, 4, LengthType.Whole);
+ var expected = @"C4,1.
+rest,4
+tie(C4,1.,1.,1.)
+tup(2,C4,C4,C4)
+";
+
+ var components = new List()
+ {
+ note,
+ new Rest(LengthType.Quarter, false),
+ new Tie(note, new List()
+ {
+ note, note
+ }),
+ new Tuplet(new List() { tupletNote, tupletNote, tupletNote}, LengthType.Half, false)
+ };
+
+ var actual = SMSCSerializer.Serialize(components);
+
+ Assert.Equal(expected, actual);
+ }
+}