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); + } +}