├── .editorconfig ├── .github └── dependabot.yml ├── .gitignore ├── GitVersion.yml ├── LICENSE ├── README.md ├── appveyor.yml ├── build.ps1 ├── build ├── build.cake ├── format-rel-notes.cake └── prompt.cake ├── dtmf-detection.sln ├── example └── dtmf-detector │ ├── Program.cs │ └── dtmf-detector.csproj ├── src ├── DtmfDetection.NAudio │ ├── AudioFile.cs │ ├── AudioStream.cs │ ├── BackgroundAnalyzer.cs │ ├── BufferedWaveProviderExt.cs │ ├── DtmfDetection.NAudio.csproj │ ├── IWaveInExt.cs │ ├── MonoSampleProvider.cs │ ├── README.md │ ├── SampleProviderExt.cs │ ├── WaveFormatExt.cs │ └── WaveStreamExt.cs └── DtmfDetection │ ├── Analyzer.cs │ ├── AudioData.cs │ ├── Config.cs │ ├── Detector.cs │ ├── DtmfChange.cs │ ├── DtmfDetection.csproj │ ├── DtmfGenerator.cs │ ├── DtmfTone.cs │ ├── FloatArrayExt.cs │ ├── Goertzel.cs │ ├── Interfaces │ ├── IAnalyzer.cs │ ├── IDetector.cs │ └── ISamples.cs │ ├── PhoneKey.cs │ ├── README.md │ ├── ToDtmfTonesExt.cs │ └── Utils.cs └── test ├── benchmark ├── Program.cs ├── benchmark.csproj ├── current-vs-last-release │ ├── Benchmarks.cs │ ├── last-release │ │ ├── AmplitudeEstimator.cs │ │ ├── AmplitudeEstimatorFactory.cs │ │ ├── DetectorConfig.cs │ │ ├── DtmfAudio.cs │ │ ├── DtmfChangeHandler.cs │ │ ├── DtmfClassification.cs │ │ ├── DtmfDetector.cs │ │ ├── DtmfTone.cs │ │ ├── ISampleSource.cs │ │ ├── NAudio │ │ │ ├── DtmfOccurence.cs │ │ │ ├── MonoSampleProvider.cs │ │ │ ├── SampleBlockProvider.cs │ │ │ ├── SampleProviderExtensions.cs │ │ │ ├── StaticSampleSource.cs │ │ │ └── WaveStreamExtensions.cs │ │ ├── PartialApplication.cs │ │ ├── PhoneKey.cs │ │ └── PureTones.cs │ └── test.mp3 └── stateless-detector │ ├── Benchmarks.cs │ ├── LessStatefulDetector.cs │ ├── LinqedDetector.cs │ ├── MuchLessStatefulDetector.cs │ └── StatefulDetector.cs ├── integration ├── AudioFileTests.cs ├── BackgroundAnalyzerTests.cs ├── FloatArrayTests.cs ├── integration.csproj ├── testdata │ ├── long_dtmf_tones.mp3 │ ├── short_dtmf_sequence.mp3 │ ├── stereo_dtmf_tones.wav │ └── very_short_dtmf_tones.ogg └── xunit.runner.json └── unit ├── AnalyzerTests.cs ├── ConfigTests.cs ├── DetectorTests.cs ├── DtmfChangeTests.cs ├── DtmfToneTests.cs ├── GoertzelTests.cs ├── PhoneKeyTests.cs ├── ToDtmfTonesTests.cs ├── unit.csproj └── xunit.runner.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Remove the line below if you want to inherit .editorconfig settings from higher directories 2 | root = true 3 | 4 | # C# files 5 | [*.cs] 6 | 7 | #### Core EditorConfig Options #### 8 | 9 | # Indentation and spacing 10 | indent_size = 4 11 | indent_style = space 12 | tab_width = 4 13 | 14 | # New line preferences 15 | end_of_line = crlf 16 | insert_final_newline = false 17 | 18 | #### .NET Coding Conventions #### 19 | 20 | # Organize usings 21 | dotnet_separate_import_directive_groups = true 22 | dotnet_sort_system_directives_first = true 23 | 24 | # this. and Me. preferences 25 | dotnet_style_qualification_for_event = false:warning 26 | dotnet_style_qualification_for_field = false:warning 27 | dotnet_style_qualification_for_method = false:warning 28 | dotnet_style_qualification_for_property = false:warning 29 | 30 | # Language keywords vs BCL types preferences 31 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 32 | dotnet_style_predefined_type_for_member_access = true:suggestion 33 | 34 | # Parentheses preferences 35 | dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion 36 | dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:suggestion 37 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion 38 | dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:suggestion 39 | 40 | # Modifier preferences 41 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 42 | 43 | # Expression-level preferences 44 | csharp_style_deconstructed_variable_declaration = true:warning 45 | csharp_style_inlined_variable_declaration = true:warning 46 | csharp_style_throw_expression = true:warning 47 | dotnet_style_coalesce_expression = true:warning 48 | dotnet_style_collection_initializer = true:warning 49 | dotnet_style_explicit_tuple_names = true:warning 50 | dotnet_style_null_propagation = true:warning 51 | dotnet_style_object_initializer = true:warning 52 | dotnet_style_prefer_auto_properties = false:warning 53 | dotnet_style_prefer_compound_assignment = true:warning 54 | dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion 55 | dotnet_style_prefer_conditional_expression_over_return = true:suggestion 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 57 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 58 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning 59 | 60 | # Field preferences 61 | dotnet_style_readonly_field = true:warning 62 | 63 | # Parameter preferences 64 | dotnet_code_quality_unused_parameters = all:warning 65 | 66 | #### C# Coding Conventions #### 67 | 68 | # var preferences 69 | csharp_style_var_elsewhere = true:warning 70 | csharp_style_var_for_built_in_types = true:warning 71 | csharp_style_var_when_type_is_apparent = true:warning 72 | 73 | # Expression-bodied members 74 | csharp_style_expression_bodied_accessors = true:suggestion 75 | csharp_style_expression_bodied_constructors = true:suggestion 76 | csharp_style_expression_bodied_indexers = true:suggestion 77 | csharp_style_expression_bodied_lambdas = true:suggestion 78 | csharp_style_expression_bodied_local_functions = true:suggestion 79 | csharp_style_expression_bodied_methods = true:suggestion 80 | csharp_style_expression_bodied_operators = true:suggestion 81 | csharp_style_expression_bodied_properties = true:suggestion 82 | 83 | # Pattern matching preferences 84 | csharp_style_pattern_matching_over_as_with_null_check = true:warning 85 | csharp_style_pattern_matching_over_is_with_cast_check = true:warning 86 | csharp_style_prefer_switch_expression = true:suggestion 87 | 88 | # Null-checking preferences 89 | csharp_style_conditional_delegate_call = true:warning 90 | 91 | # Modifier preferences 92 | csharp_prefer_static_local_function = true:suggestion 93 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async 94 | 95 | # Code-block preferences 96 | csharp_prefer_braces = when_multiline:suggestion 97 | csharp_prefer_simple_using_statement = true:warning 98 | 99 | # Expression-level preferences 100 | csharp_prefer_simple_default_expression = true:warning 101 | csharp_style_pattern_local_over_anonymous_function = true:warning 102 | csharp_style_prefer_index_operator = true:warning 103 | csharp_style_prefer_range_operator = true:warning 104 | csharp_style_unused_value_assignment_preference = discard_variable:warning 105 | csharp_style_unused_value_expression_statement_preference = discard_variable:warning 106 | 107 | # 'using' directive preferences 108 | csharp_using_directive_placement = inside_namespace:warning 109 | 110 | #### C# Formatting Rules #### 111 | 112 | # New line preferences 113 | csharp_new_line_before_catch = false 114 | csharp_new_line_before_else = false 115 | csharp_new_line_before_finally = false 116 | csharp_new_line_before_members_in_anonymous_types = false 117 | csharp_new_line_before_members_in_object_initializers = false 118 | csharp_new_line_before_open_brace = none 119 | csharp_new_line_between_query_expression_clauses = true 120 | 121 | # Indentation preferences 122 | csharp_indent_block_contents = true 123 | csharp_indent_braces = false 124 | csharp_indent_case_contents = true 125 | csharp_indent_case_contents_when_block = true 126 | csharp_indent_labels = no_change 127 | csharp_indent_switch_labels = true 128 | 129 | # Space preferences 130 | csharp_space_after_cast = false 131 | csharp_space_after_colon_in_inheritance_clause = true 132 | csharp_space_after_comma = true 133 | csharp_space_after_dot = false 134 | csharp_space_after_keywords_in_control_flow_statements = true 135 | csharp_space_after_semicolon_in_for_statement = true 136 | csharp_space_around_binary_operators = before_and_after 137 | csharp_space_around_declaration_statements = false 138 | csharp_space_before_colon_in_inheritance_clause = true 139 | csharp_space_before_comma = false 140 | csharp_space_before_dot = false 141 | csharp_space_before_open_square_brackets = false 142 | csharp_space_before_semicolon_in_for_statement = false 143 | csharp_space_between_empty_square_brackets = false 144 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 145 | csharp_space_between_method_call_name_and_opening_parenthesis = false 146 | csharp_space_between_method_call_parameter_list_parentheses = false 147 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 148 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 149 | csharp_space_between_method_declaration_parameter_list_parentheses = false 150 | csharp_space_between_parentheses = false 151 | csharp_space_between_square_brackets = false 152 | 153 | # Wrapping preferences 154 | csharp_preserve_single_line_blocks = true 155 | csharp_preserve_single_line_statements = true 156 | 157 | #### Naming styles #### 158 | 159 | # Naming rules 160 | 161 | dotnet_naming_rule.interface_should_be_pascal_case.severity = warning 162 | dotnet_naming_rule.interface_should_be_pascal_case.symbols = interface 163 | dotnet_naming_rule.interface_should_be_pascal_case.style = pascal_case 164 | 165 | dotnet_naming_rule.types_should_be_pascal_case.severity = warning 166 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 167 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 168 | 169 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning 170 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 171 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 172 | 173 | # Symbol specifications 174 | 175 | dotnet_naming_symbols.interface.applicable_kinds = interface 176 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 177 | dotnet_naming_symbols.interface.required_modifiers = 178 | 179 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 180 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 181 | dotnet_naming_symbols.types.required_modifiers = 182 | 183 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 184 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 185 | dotnet_naming_symbols.non_field_members.required_modifiers = 186 | 187 | # Naming styles 188 | 189 | dotnet_naming_style.pascal_case.required_prefix = 190 | dotnet_naming_style.pascal_case.required_suffix = 191 | dotnet_naming_style.pascal_case.word_separator = 192 | dotnet_naming_style.pascal_case.capitalization = pascal_case 193 | 194 | # CA1051: Do not declare visible instance fields 195 | dotnet_diagnostic.CA1051.severity = silent 196 | 197 | # RCS1123: Add parentheses according to operator precedence. 198 | dotnet_diagnostic.RCS1123.severity = silent 199 | 200 | # CA1303: Do not pass literals as localized parameters 201 | dotnet_diagnostic.CA1303.severity = silent 202 | 203 | # CA1814: Prefer jagged arrays over multidimensional 204 | dotnet_diagnostic.CA1814.severity = suggestion 205 | 206 | # CA1062: Validate arguments of public methods 207 | dotnet_diagnostic.CA1062.severity = suggestion 208 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "nuget" 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "daily" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | # Custom 353 | .vscode/ 354 | build/tools/ 355 | BenchmarkDotNet.Artifacts/ 356 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: Mainline 2 | branches: 3 | master: 4 | increment: Minor 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Robert Hofmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: '{build}' 2 | pull_requests: 3 | do_not_increment_build_number: true 4 | skip_tags: true 5 | image: Visual Studio 2019 6 | environment: 7 | nuget_key: 8 | secure: xKXJVMt+HIIZYdc4vhBbJRN2DvLa7OMt4u2twNXyho3iv0etB7dSCnTYZG1wY7Xm 9 | build_script: 10 | - ps: ./build 11 | test: off 12 | deploy_script: 13 | - ps: ./build -Target Release 14 | -------------------------------------------------------------------------------- /build/build.cake: -------------------------------------------------------------------------------- 1 | #tool nuget:?package=GitVersion.CommandLine&version=5.6.3 2 | #tool Codecov 3 | #addin Cake.Codecov 4 | #addin Cake.Git 5 | #load prompt.cake 6 | #load format-rel-notes.cake 7 | 8 | var target = Argument("target", "Default"); 9 | var config = Argument("configuration", "Release"); 10 | var nugetKey = Argument("nugetKey", null) ?? EnvironmentVariable("nuget_key"); 11 | 12 | var rootDir = Directory(".."); 13 | var testDir = rootDir + Directory("test"); 14 | 15 | var lastCommitMsg = EnvironmentVariable("APPVEYOR_REPO_COMMIT_MESSAGE") ?? GitLogTip(rootDir).MessageShort; 16 | var lastCommitSha = EnvironmentVariable("APPVEYOR_REPO_COMMIT") ?? GitLogTip(rootDir).Sha; 17 | var currBranch = GitBranchCurrent(rootDir).FriendlyName; 18 | GitVersion semVer = null; 19 | 20 | Task("SemVer") 21 | .Does(() => { 22 | semVer = GitVersion(); 23 | Information($"{semVer.FullSemVer} ({lastCommitMsg})"); 24 | }); 25 | 26 | Task("Clean") 27 | .Does(() => 28 | DotNetCoreClean(rootDir, new DotNetCoreCleanSettings { 29 | Configuration = config, 30 | Verbosity = DotNetCoreVerbosity.Minimal 31 | })); 32 | 33 | Task("Build") 34 | .IsDependentOn("SemVer") 35 | .Does(() => 36 | DotNetCoreBuild(rootDir, new DotNetCoreBuildSettings { 37 | Configuration = config, 38 | MSBuildSettings = new DotNetCoreMSBuildSettings() 39 | .SetVersion(semVer.AssemblySemVer) 40 | })); 41 | 42 | Task("Test") 43 | .IsDependentOn("Build") 44 | .Does(() => 45 | DotNetCoreTest(rootDir, new DotNetCoreTestSettings { 46 | Configuration = config, 47 | NoBuild = true, 48 | ArgumentCustomization = args => { 49 | var msbuildSettings = new DotNetCoreMSBuildSettings() 50 | .WithProperty("CollectCoverage", new[] { "true" }) 51 | .WithProperty("CoverletOutputFormat", new[] { "opencover" }); 52 | args.AppendMSBuildSettings(msbuildSettings, environment: null); 53 | return args; 54 | } 55 | })); 56 | 57 | Task("UploadCoverage") 58 | .Does(() => { 59 | Codecov(testDir + File("unit/coverage.opencover.xml")); 60 | Codecov(testDir + File("integration/coverage.opencover.xml")); 61 | }); 62 | 63 | Task("Pack-DtmfDetection") 64 | .IsDependentOn("SemVer") 65 | .Does(() => { 66 | var relNotes = FormatReleaseNotes(lastCommitMsg); 67 | Information($"Packing {semVer.NuGetVersion} ({relNotes})"); 68 | 69 | var pkgName = "DtmfDetection"; 70 | var pkgDesc = $"Implementation of the Goertzel algorithm for the detection of DTMF tones (aka touch tones) in audio data. Install the package \"DtmfDetection.NAudio\" for integration with NAudio.\r\n\r\nDocumentation: https://github.com/bert2/DtmfDetection\r\n\r\nRelease notes: {relNotes}"; 71 | var pkgTags = "Goertzel; DTMF; touch tone; detection; dsp; signal processing; audio analysis"; 72 | var libDir = rootDir + Directory($"src/{pkgName}"); 73 | var pkgDir = libDir + Directory($"bin/{config}"); 74 | 75 | var msbuildSettings = new DotNetCoreMSBuildSettings(); 76 | msbuildSettings.Properties["PackageId"] = new[] { pkgName }; 77 | msbuildSettings.Properties["PackageVersion"] = new[] { semVer.NuGetVersion }; 78 | msbuildSettings.Properties["Title"] = new[] { pkgName }; 79 | msbuildSettings.Properties["Description"] = new[] { pkgDesc }; 80 | msbuildSettings.Properties["PackageTags"] = new[] { pkgTags }; 81 | msbuildSettings.Properties["PackageReleaseNotes"] = new[] { relNotes }; 82 | msbuildSettings.Properties["Authors"] = new[] { "Robert Hofmann" }; 83 | msbuildSettings.Properties["RepositoryUrl"] = new[] { "https://github.com/bert2/DtmfDetection.git" }; 84 | msbuildSettings.Properties["RepositoryCommit"] = new[] { lastCommitSha }; 85 | msbuildSettings.Properties["PackageLicenseExpression"] = new[] { "MIT" }; 86 | msbuildSettings.Properties["IncludeSource"] = new[] { "true" }; 87 | msbuildSettings.Properties["IncludeSymbols"] = new[] { "true" }; 88 | msbuildSettings.Properties["SymbolPackageFormat"] = new[] { "snupkg" }; 89 | 90 | DotNetCorePack(libDir, new DotNetCorePackSettings { 91 | Configuration = config, 92 | OutputDirectory = pkgDir, 93 | NoBuild = true, 94 | NoDependencies = false, 95 | MSBuildSettings = msbuildSettings 96 | }); 97 | }); 98 | 99 | Task("Pack-DtmfDetection.NAudio") 100 | .IsDependentOn("SemVer") 101 | .Does(() => { 102 | var relNotes = FormatReleaseNotes(lastCommitMsg); 103 | Information($"Packing {semVer.NuGetVersion} ({relNotes})"); 104 | 105 | var pkgName = "DtmfDetection.NAudio"; 106 | var pkgDesc = $"Extends NAudio with means to detect DTMF tones (aka touch tones) in live audio data and audio files.\r\n\r\nDocumentation: https://github.com/bert2/DtmfDetection\r\n\r\nRelease notes: {relNotes}"; 107 | var pkgTags = "Goertzel; DTMF; touch tone; detection; dsp; signal processing; audio analysis; NAudio"; 108 | var libDir = rootDir + Directory($"src/{pkgName}"); 109 | var pkgDir = libDir + Directory($"bin/{config}"); 110 | 111 | var msbuildSettings = new DotNetCoreMSBuildSettings(); 112 | msbuildSettings.Properties["PackageId"] = new[] { pkgName }; 113 | msbuildSettings.Properties["PackageVersion"] = new[] { semVer.NuGetVersion }; 114 | msbuildSettings.Properties["Title"] = new[] { pkgName }; 115 | msbuildSettings.Properties["Description"] = new[] { pkgDesc }; 116 | msbuildSettings.Properties["PackageTags"] = new[] { pkgTags }; 117 | msbuildSettings.Properties["PackageReleaseNotes"] = new[] { relNotes }; 118 | msbuildSettings.Properties["Authors"] = new[] { "Robert Hofmann" }; 119 | msbuildSettings.Properties["RepositoryUrl"] = new[] { "https://github.com/bert2/DtmfDetection.git" }; 120 | msbuildSettings.Properties["RepositoryCommit"] = new[] { lastCommitSha }; 121 | msbuildSettings.Properties["PackageLicenseExpression"] = new[] { "MIT" }; 122 | msbuildSettings.Properties["IncludeSource"] = new[] { "true" }; 123 | msbuildSettings.Properties["IncludeSymbols"] = new[] { "true" }; 124 | msbuildSettings.Properties["SymbolPackageFormat"] = new[] { "snupkg" }; 125 | 126 | DotNetCorePack(libDir, new DotNetCorePackSettings { 127 | Configuration = config, 128 | OutputDirectory = pkgDir, 129 | NoBuild = true, 130 | NoDependencies = false, 131 | MSBuildSettings = msbuildSettings 132 | }); 133 | }); 134 | 135 | Task("Release-DtmfDetection") 136 | .IsDependentOn("Pack-DtmfDetection") 137 | .Does(() => { 138 | if (currBranch != "master") { 139 | Information($"Will not release package built from branch '{currBranch}'."); 140 | return; 141 | } 142 | 143 | if (lastCommitMsg.Contains("without release")) { 144 | Information($"Skipping release to nuget.org"); 145 | return; 146 | } 147 | 148 | Information($"Releasing {semVer.NuGetVersion} to nuget.org"); 149 | 150 | if (string.IsNullOrEmpty(nugetKey)) 151 | nugetKey = Prompt("Enter nuget API key: "); 152 | 153 | var pkgName = "DtmfDetection"; 154 | var pkgDir = rootDir + Directory($"src/{pkgName}/bin/{config}"); 155 | 156 | DotNetCoreNuGetPush( 157 | pkgDir + File($"{pkgName}.{semVer.NuGetVersion}.nupkg"), 158 | new DotNetCoreNuGetPushSettings { 159 | Source = "nuget.org", 160 | ApiKey = nugetKey 161 | }); 162 | }); 163 | 164 | Task("Release-DtmfDetection.NAudio") 165 | .IsDependentOn("Pack-DtmfDetection.NAudio") 166 | .Does(() => { 167 | if (currBranch != "master") { 168 | Information($"Will not release package built from branch '{currBranch}'."); 169 | return; 170 | } 171 | 172 | if (lastCommitMsg.Contains("without release")) { 173 | Information($"Skipping release to nuget.org"); 174 | return; 175 | } 176 | 177 | Information($"Releasing {semVer.NuGetVersion} to nuget.org"); 178 | 179 | if (string.IsNullOrEmpty(nugetKey)) 180 | nugetKey = Prompt("Enter nuget API key: "); 181 | 182 | var pkgName = "DtmfDetection.NAudio"; 183 | var pkgDir = rootDir + Directory($"src/{pkgName}/bin/{config}"); 184 | 185 | DotNetCoreNuGetPush( 186 | pkgDir + File($"{pkgName}.{semVer.NuGetVersion}.nupkg"), 187 | new DotNetCoreNuGetPushSettings { 188 | Source = "nuget.org", 189 | ApiKey = nugetKey 190 | }); 191 | }); 192 | 193 | Task("Default") 194 | .IsDependentOn("SemVer") 195 | .IsDependentOn("Clean") 196 | .IsDependentOn("Build") 197 | .IsDependentOn("Test"); 198 | 199 | Task("Pack") 200 | .IsDependentOn("Pack-DtmfDetection") 201 | .IsDependentOn("Pack-DtmfDetection.NAudio"); 202 | 203 | Task("Release") 204 | .IsDependentOn("UploadCoverage") 205 | .IsDependentOn("Release-DtmfDetection") 206 | .IsDependentOn("Release-DtmfDetection.NAudio"); 207 | 208 | RunTarget(target); 209 | -------------------------------------------------------------------------------- /build/format-rel-notes.cake: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | string FormatReleaseNotes(string text) { 4 | if (string.IsNullOrEmpty(text)) throw new Exception("Release notes are empty."); 5 | 6 | return MakeSentence(RemoveIncrementFlag(text).Trim()); 7 | } 8 | 9 | string RemoveIncrementFlag(string text) => 10 | Regex.Replace(text, @"\+semver:\s?(breaking|major|feature|minor|fix|patch|none|skip)", string.Empty); 11 | 12 | string MakeSentence(string text) { 13 | var withUpperFirst = text.Skip(1).Prepend(char.ToUpper(text.First())); 14 | var withPeriod = text.Last() == '.' ? withUpperFirst : withUpperFirst.Append('.'); 15 | 16 | return string.Concat(withPeriod); 17 | } 18 | -------------------------------------------------------------------------------- /build/prompt.cake: -------------------------------------------------------------------------------- 1 | using _Task = System.Threading.Tasks.Task; 2 | 3 | string Prompt(string message, TimeSpan? timeout = null) { 4 | Warning(message); 5 | Console.Write("> "); 6 | 7 | string response = null; 8 | 9 | _Task.WhenAny( 10 | _Task.Run(() => response = Console.ReadLine()), 11 | _Task.Delay(timeout ?? TimeSpan.FromSeconds(30)) 12 | ).Wait(); 13 | 14 | if (response == null) 15 | throw new Exception($"User prompt timed out."); 16 | 17 | return response; 18 | } 19 | -------------------------------------------------------------------------------- /dtmf-detection.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29411.108 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B6FAA2B4-AE11-4048-882A-62B8DCBF9B53}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{FAD6B8C4-3518-460A-A6B5-706C9F2B64F8}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtmfDetection", "src\DtmfDetection\DtmfDetection.csproj", "{06F99F1B-4863-4DBC-AA99-18082F387DEF}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "example", "example", "{756D4506-5AC2-47CF-8A9E-2BD532046C69}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unit", "test\unit\unit.csproj", "{97D8B309-CB2E-47C8-BA33-42412E32377F}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "benchmark", "test\benchmark\benchmark.csproj", "{B530CBC3-E69E-4C0E-AE72-6DDF3F14A106}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C165C447-5F2D-4612-AA4E-2E82FB575DB4}" 19 | ProjectSection(SolutionItems) = preProject 20 | .editorconfig = .editorconfig 21 | .gitignore = .gitignore 22 | README.md = README.md 23 | EndProjectSection 24 | EndProject 25 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtmfDetection.NAudio", "src\DtmfDetection.NAudio\DtmfDetection.NAudio.csproj", "{D5E2192E-B326-45D0-877D-7ACB67242DBF}" 26 | EndProject 27 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "integration", "test\integration\integration.csproj", "{B03A2C7A-4115-4548-9024-D722E96D1286}" 28 | EndProject 29 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dtmf-detector", "example\dtmf-detector\dtmf-detector.csproj", "{A550BCB4-879D-406A-AE14-0DCE55FB6F26}" 30 | EndProject 31 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{8093EB61-64CA-42E8-BE9C-52ABCB3F862C}" 32 | ProjectSection(SolutionItems) = preProject 33 | appveyor.yml = appveyor.yml 34 | build\build.cake = build\build.cake 35 | build.ps1 = build.ps1 36 | build\format-rel-notes.cake = build\format-rel-notes.cake 37 | GitVersion.yml = GitVersion.yml 38 | build\prompt.cake = build\prompt.cake 39 | EndProjectSection 40 | EndProject 41 | Global 42 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 43 | Debug|Any CPU = Debug|Any CPU 44 | Release|Any CPU = Release|Any CPU 45 | EndGlobalSection 46 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 47 | {06F99F1B-4863-4DBC-AA99-18082F387DEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {06F99F1B-4863-4DBC-AA99-18082F387DEF}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {06F99F1B-4863-4DBC-AA99-18082F387DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {06F99F1B-4863-4DBC-AA99-18082F387DEF}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {97D8B309-CB2E-47C8-BA33-42412E32377F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {97D8B309-CB2E-47C8-BA33-42412E32377F}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {97D8B309-CB2E-47C8-BA33-42412E32377F}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {97D8B309-CB2E-47C8-BA33-42412E32377F}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {B530CBC3-E69E-4C0E-AE72-6DDF3F14A106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {B530CBC3-E69E-4C0E-AE72-6DDF3F14A106}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {B530CBC3-E69E-4C0E-AE72-6DDF3F14A106}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {B530CBC3-E69E-4C0E-AE72-6DDF3F14A106}.Release|Any CPU.Build.0 = Release|Any CPU 59 | {D5E2192E-B326-45D0-877D-7ACB67242DBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {D5E2192E-B326-45D0-877D-7ACB67242DBF}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {D5E2192E-B326-45D0-877D-7ACB67242DBF}.Release|Any CPU.ActiveCfg = Release|Any CPU 62 | {D5E2192E-B326-45D0-877D-7ACB67242DBF}.Release|Any CPU.Build.0 = Release|Any CPU 63 | {B03A2C7A-4115-4548-9024-D722E96D1286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 64 | {B03A2C7A-4115-4548-9024-D722E96D1286}.Debug|Any CPU.Build.0 = Debug|Any CPU 65 | {B03A2C7A-4115-4548-9024-D722E96D1286}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {B03A2C7A-4115-4548-9024-D722E96D1286}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {A550BCB4-879D-406A-AE14-0DCE55FB6F26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 68 | {A550BCB4-879D-406A-AE14-0DCE55FB6F26}.Debug|Any CPU.Build.0 = Debug|Any CPU 69 | {A550BCB4-879D-406A-AE14-0DCE55FB6F26}.Release|Any CPU.ActiveCfg = Release|Any CPU 70 | {A550BCB4-879D-406A-AE14-0DCE55FB6F26}.Release|Any CPU.Build.0 = Release|Any CPU 71 | EndGlobalSection 72 | GlobalSection(SolutionProperties) = preSolution 73 | HideSolutionNode = FALSE 74 | EndGlobalSection 75 | GlobalSection(NestedProjects) = preSolution 76 | {06F99F1B-4863-4DBC-AA99-18082F387DEF} = {B6FAA2B4-AE11-4048-882A-62B8DCBF9B53} 77 | {97D8B309-CB2E-47C8-BA33-42412E32377F} = {FAD6B8C4-3518-460A-A6B5-706C9F2B64F8} 78 | {B530CBC3-E69E-4C0E-AE72-6DDF3F14A106} = {FAD6B8C4-3518-460A-A6B5-706C9F2B64F8} 79 | {D5E2192E-B326-45D0-877D-7ACB67242DBF} = {B6FAA2B4-AE11-4048-882A-62B8DCBF9B53} 80 | {B03A2C7A-4115-4548-9024-D722E96D1286} = {FAD6B8C4-3518-460A-A6B5-706C9F2B64F8} 81 | {A550BCB4-879D-406A-AE14-0DCE55FB6F26} = {756D4506-5AC2-47CF-8A9E-2BD532046C69} 82 | {8093EB61-64CA-42E8-BE9C-52ABCB3F862C} = {C165C447-5F2D-4612-AA4E-2E82FB575DB4} 83 | EndGlobalSection 84 | GlobalSection(ExtensibilityGlobals) = postSolution 85 | SolutionGuid = {E6726D07-1D7A-4961-8843-79BD7A83E97F} 86 | EndGlobalSection 87 | EndGlobal 88 | -------------------------------------------------------------------------------- /example/dtmf-detector/Program.cs: -------------------------------------------------------------------------------- 1 | namespace dtmf_detector { 2 | using System; 3 | using NAudio.Wave; 4 | using DtmfDetection; 5 | using DtmfDetection.NAudio; 6 | using System.Reflection; 7 | using System.Runtime.InteropServices; 8 | using System.ComponentModel; 9 | using NAudio.CoreAudioApi; 10 | 11 | public static class Program { 12 | public static int Main(string[] args) { 13 | try { 14 | PrintHeader(); 15 | 16 | if (args.Length == 0) 17 | MenuLoop(); 18 | else if (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase)) 19 | return 0; 20 | else if (string.Equals(args[0], "--help", StringComparison.OrdinalIgnoreCase)) 21 | PrintHelp(); 22 | else 23 | AnalyzeFile(path: args[0]); 24 | 25 | return 0; 26 | } catch (Exception e) { 27 | Console.WriteLine(e); 28 | return 1; 29 | } 30 | } 31 | 32 | private static void PrintHeader() { 33 | var v = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(); 34 | Console.WriteLine($"dtmf-detector {v.Major}.{v.Minor}.{v.Build} (https://github.com/bert2/DtmfDetection)\n"); 35 | } 36 | 37 | private static void PrintHelp() { 38 | Console.WriteLine("USAGE:"); 39 | Console.WriteLine(); 40 | Console.WriteLine(" dtmf-detector.exe Run DTMF detection on the specified file and"); 41 | Console.WriteLine(" print all detected DTMF tones."); 42 | Console.WriteLine(); 43 | Console.WriteLine(" dtmf-detector.exe Run interactive mode to detect DMTF tones in"); 44 | Console.WriteLine(" audio ouput or microphone input."); 45 | Console.WriteLine(); 46 | Console.WriteLine(" dtmf-detector.exe --version Print version."); 47 | Console.WriteLine(); 48 | Console.WriteLine(" dtmf-detector.exe --help Show this usage information."); 49 | } 50 | 51 | private static void AnalyzeFile(string path) { 52 | AudioFileReader audioFile; 53 | try { 54 | // supports .mp3, .wav, aiff, and Windows Media Foundation formats 55 | audioFile = new AudioFileReader(path); 56 | } catch (COMException e) when (e.ErrorCode == unchecked((int)0xC00D36C4)) { 57 | throw new InvalidOperationException("Unsupported media type", e); 58 | } 59 | 60 | Console.WriteLine($"DTMF tones found in file '{audioFile.FileName}':\n"); 61 | foreach (var dtmf in audioFile.DtmfChanges().ToDtmfTones()) 62 | Console.WriteLine(dtmf); 63 | } 64 | 65 | private static void MenuLoop() { 66 | var state = State.NotAnalyzing; 67 | IWaveIn? audioSource = null; 68 | BackgroundAnalyzer? analyzer = null; 69 | 70 | while (true) { 71 | PrintMenu(state); 72 | var key = Console.ReadKey(intercept: true); 73 | switch (key.Key, state) { 74 | case (ConsoleKey.Escape, State.NotAnalyzing): 75 | return; 76 | case (ConsoleKey.Escape, State.AnalyzingMicIn): 77 | case (ConsoleKey.Escape, State.AnalyzingOutput): 78 | state = State.NotAnalyzing; 79 | analyzer?.Dispose(); 80 | audioSource?.Dispose(); 81 | break; 82 | case (ConsoleKey.M, State.NotAnalyzing): 83 | state = State.AnalyzingMicIn; 84 | audioSource = new WaveInEvent { WaveFormat = new WaveFormat(Config.Default.SampleRate, bits: 32, channels: 1) }; 85 | analyzer = new BackgroundAnalyzer(audioSource, config: Config.Default.WithThreshold(10)); 86 | analyzer.OnDtmfDetected += dtmf => Console.WriteLine(dtmf); 87 | break; 88 | case (ConsoleKey.O, State.NotAnalyzing): 89 | state = State.AnalyzingOutput; 90 | audioSource = new WasapiLoopbackCapture { ShareMode = AudioClientShareMode.Shared }; 91 | analyzer = new BackgroundAnalyzer(audioSource); 92 | analyzer.OnDtmfDetected += dtmf => Console.WriteLine(dtmf); 93 | break; 94 | } 95 | } 96 | } 97 | 98 | private static void PrintMenu(State state) { 99 | Console.WriteLine("----------------------------------"); 100 | 101 | if (state != State.NotAnalyzing) Console.ForegroundColor = ConsoleColor.Red; 102 | Console.Write(" ■ "); 103 | Console.ResetColor(); 104 | Console.WriteLine($"{state.ToPrettyString()}"); 105 | 106 | Console.WriteLine("----------------------------------"); 107 | 108 | Console.WriteLine(state switch { 109 | State.NotAnalyzing => "[M] analyze microphone input\n" 110 | + "[O] analyze current audio output\n" 111 | + "[Esc] quit", 112 | _ => "[Esc] stop analyzing" 113 | }); 114 | 115 | Console.WriteLine(); 116 | } 117 | 118 | private static string ToPrettyString(this State state) => state switch { 119 | State.NotAnalyzing => "not analyzing", 120 | State.AnalyzingMicIn => "analyzing microphone input", 121 | State.AnalyzingOutput => "analyzing current audio output", 122 | _ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(State)) 123 | }; 124 | 125 | private enum State { 126 | NotAnalyzing, 127 | AnalyzingMicIn, 128 | AnalyzingOutput 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /example/dtmf-detector/dtmf-detector.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | enable 7 | dtmf_detector 8 | dtmf-detector 9 | 10 | 11 | 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/AudioFile.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | using DtmfDetection.Interfaces; 4 | using global::NAudio.Wave; 5 | 6 | /// Convenience implementation of `ISamples` for `WaveStream` audio. `WaveStream`s are commonly used for finite data like audio files. The `Analyzer` uses `ISamples` to read the input data in blocks and feed them to the `Detector`. 7 | public class AudioFile : ISamples { 8 | private readonly WaveStream source; 9 | private readonly ISampleProvider samples; 10 | 11 | /// Returns the number of channels in the `WaveStream` input or `1` when mono-conversion has been enabled. 12 | public int Channels => samples.WaveFormat.Channels; 13 | 14 | /// Returns the target sample rate this `AudioFile` has been created with. 15 | public int SampleRate => samples.WaveFormat.SampleRate; 16 | 17 | /// Returns the current position of the input `WaveStream`. 18 | public TimeSpan Position => source.CurrentTime; 19 | 20 | /// Creates a new `AudioFile` from a `WaveStream` input. Also resamples the input and optionally converts it to single-channel audio. 21 | /// The input audio data (typically finite). 22 | /// Used to resample the `WaveStream` input. This should match the sample rate (in Hz) the `Analyzer` is using (via `Config.SampleRate`). 23 | /// Toggles conversion of multi-channel audio to mono. 24 | public AudioFile(WaveStream source, int targetSampleRate, bool forceMono = true) { 25 | this.source = source; 26 | 27 | var samples = forceMono ? source.ToSampleProvider().AsMono() : source.ToSampleProvider(); 28 | this.samples = samples.Resample(targetSampleRate); 29 | } 30 | 31 | /// Reads `count` samples from the input and writes them into `buffer`. 32 | /// The output array to write the read samples to. 33 | /// The number of samples to read. 34 | /// The number of samples that have been read. Will always equal `count` except when the end of the input has been reached, in which case `Read()` returns a number less than `count`. 35 | public int Read(float[] buffer, int count) => samples.Read(buffer, 0, count); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/AudioStream.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | using DtmfDetection.Interfaces; 4 | using global::NAudio.Wave; 5 | 6 | /// Convenience implementation of `ISamples` for an infinite `IWaveIn` audio stream. The `Analyzer` uses `ISamples` to read the input data in blocks and feed them to the `Detector`. 7 | public class AudioStream : ISamples { 8 | private readonly BufferedWaveProvider source; 9 | private readonly ISampleProvider samples; 10 | private volatile bool stopRequested; 11 | 12 | /// Returns the number of channels in the `IWaveIn` input or `1` when mono-conversion has been enabled. 13 | public int Channels => samples.WaveFormat.Channels; 14 | 15 | /// Returns the target sample rate this `AudioStream` has been created with. 16 | public int SampleRate => samples.WaveFormat.SampleRate; 17 | 18 | /// Simply calls `DateTime.Now.TimeOfDay` and returns the result. 19 | public TimeSpan Position => DateTime.Now.TimeOfDay; 20 | 21 | /// Creates a new `AudioStream` from an `IWaveIn` input by buffering it with a `BufferedWaveProvider`. Also resamples the input and optionally converts it to single-channel audio. 22 | /// The infinite input audio stream. 23 | /// Used to resample the `IWaveIn` input. This should match the sample rate (in Hz) the `Analyzer` is using (via `Config.SampleRate`). 24 | /// Toggles conversion of multi-channel audio to mono. 25 | public AudioStream(IWaveIn source, int targetSampleRate, bool forceMono = true) { 26 | this.source = source.ToBufferedWaveProvider(); 27 | var samples = forceMono ? this.source.ToSampleProvider().AsMono() : this.source.ToSampleProvider(); 28 | this.samples = samples.Resample(targetSampleRate); 29 | } 30 | 31 | /// Reads `count` samples from the input and writes them into `buffer`. Will block as long as it takes for the input to buffer the requested number of samples. 32 | /// The output array to write the read samples to. 33 | /// The number of samples to read. 34 | /// The number of samples that have been read. Will always equal `count` except when `StopWaiting()` has been called,in which case `Read()` returns `0`. 35 | public int Read(float[] buffer, int count) { 36 | while (source.WaitForSamples(count)) { 37 | if (stopRequested) return 0; 38 | } 39 | 40 | return samples.Read(buffer, 0, count); 41 | } 42 | 43 | /// Stops waiting for the input to buffer data. Some `IWaveIn`s don't have data available continuously. For instance a `WasapiLoopbackCapture` will only have data as long as the OS is playing some audio. Calling `StopWaiting()` will break the infinite wait loop and the `Analyzer` processing this `AudioStream` will consider it being "finished". This in turn helps to gracefully exit the thread running the analysis. 44 | public void StopWaiting() => stopRequested = true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/BackgroundAnalyzer.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | using System.Threading; 4 | using DtmfDetection.Interfaces; 5 | using global::NAudio.Wave; 6 | 7 | /// Helper that does audio analysis in a background thread. Useful when analyzing infinite inputs like mic-in our the current audio output. 8 | public class BackgroundAnalyzer : IDisposable { 9 | private readonly IWaveIn source; 10 | private readonly AudioStream samples; 11 | private readonly IAnalyzer analyzer; 12 | private Thread? captureWorker; 13 | 14 | /// Fired when a DTMF change (a DTMF tone started or stopped) has been detected. 15 | public event Action? OnDtmfDetected; 16 | 17 | /// Creates a new `BackgroundAnalyzer` and immediately starts listening to the `IWaveIn` input. `Dispose()` this instance to stop the background thread doing the analysis. 18 | /// The input data. Must not be in recording state. 19 | /// Toggles conversion of multi-channel audio to mono before the analysis. 20 | /// Optional handler for the `OnDtmfDetected` event. 21 | /// Optional detector configuration. Defaults to `Config.Default`. 22 | /// Optional; can be used to inject a custom analyzer implementation. Defaults to `Analyzer`. 23 | public BackgroundAnalyzer( 24 | IWaveIn source, 25 | bool forceMono = true, 26 | Action? onDtmfDetected = null, 27 | Config? config = null, 28 | IAnalyzer? analyzer = null) { 29 | this.source = source; 30 | OnDtmfDetected += onDtmfDetected; 31 | var cfg = config ?? Config.Default; 32 | samples = new AudioStream(source, cfg.SampleRate, forceMono); 33 | this.analyzer = analyzer ?? Analyzer.Create(samples, cfg); 34 | StartCapturing(); 35 | } 36 | 37 | /// Calls `IWaveIn.StopRecording()` on the input stream and halts the background thread doing the analysis. 38 | public void Dispose() => StopCapturing(); 39 | 40 | private void StartCapturing() { 41 | source.StartRecording(); 42 | captureWorker = new Thread(Analyze); 43 | captureWorker.Start(); 44 | } 45 | 46 | private void StopCapturing() { 47 | source.StopRecording(); 48 | samples.StopWaiting(); 49 | captureWorker?.Join(); 50 | } 51 | 52 | private void Analyze() { 53 | while (analyzer.MoreSamplesAvailable) { 54 | foreach (var dtmf in analyzer.AnalyzeNextBlock()) 55 | OnDtmfDetected?.Invoke(dtmf); 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/BufferedWaveProviderExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | using System.Threading; 4 | using global::NAudio.Wave; 5 | 6 | /// Provides an extension method that waits until a `BufferedWaveProvider` has read enough data. 7 | public static class BufferedWaveProviderExt { 8 | /// Blocks the thread for as long as the `BufferedWaveProvider` minimally should need to buffer at least `count` sample frames. The wait time is estimated from the difference of the number of already buffered bytes to the number of requested bytes. 9 | /// The buffered source of input data. 10 | /// The requested number of samples frames. Used to calculate the number of requested bytes. 11 | /// Returns `false` when the estimated wait time was sufficient to fill the buffer, or `true` when more waiting is needed. 12 | public static bool WaitForSamples(this BufferedWaveProvider source, int count) { 13 | var missingBytes = source.WaveFormat.BlockAlign * count - source.BufferedBytes; 14 | 15 | if (missingBytes > 0) { 16 | var waitTime = source.WaveFormat.ConvertByteSizeToLatency(missingBytes); 17 | Thread.Sleep(Math.Max(waitTime, 1)); 18 | } 19 | 20 | return source.BufferedBytes < missingBytes; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/DtmfDetection.NAudio.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | enable 6 | .\obj\DtmfDetection.NAudio.xml 7 | 8 | 9 | 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/IWaveInExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using global::NAudio.Wave; 3 | 4 | /// Provides an extensions method to buffer a `IWaveIn` stream using a `BufferedWaveProvider`. 5 | public static class IWaveInExt { 6 | /// Creates a `BufferedWaveProvider` for a `IWaveIn` and returns it. 7 | /// The `IWaveIn` stream providing the input data. 8 | /// The `BufferedWaveProvider` buffering the input stream. 9 | public static BufferedWaveProvider ToBufferedWaveProvider(this IWaveIn source) { 10 | var buffer = new BufferedWaveProvider(source.WaveFormat) { DiscardOnBufferOverflow = true }; 11 | source.DataAvailable += (_, e) => buffer.AddSamples(e.Buffer, 0, e.BytesRecorded); 12 | return buffer; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/MonoSampleProvider.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | 4 | using global::NAudio.Utils; 5 | using global::NAudio.Wave; 6 | 7 | /// Decorates an `ISampleProvider` with a mono-conversion step. 8 | public class MonoSampleProvider : ISampleProvider { 9 | private readonly ISampleProvider sourceProvider; 10 | 11 | private readonly int sourceChannels; 12 | 13 | private float[] sourceBuffer = Array.Empty(); 14 | 15 | /// Creates a new `MonoSampleProvider` from a multi-channel `ISampleProvider`. 16 | /// The `ISampleProvider` providing the source samples. 17 | public MonoSampleProvider(ISampleProvider sourceProvider) { 18 | this.sourceProvider = sourceProvider; 19 | sourceChannels = sourceProvider.WaveFormat.Channels; 20 | WaveFormat = new WaveFormat( 21 | sourceProvider.WaveFormat.SampleRate, 22 | sourceProvider.WaveFormat.BitsPerSample, 23 | channels: 1); 24 | } 25 | 26 | /// The `WaveFormat` of the decorated `ISampleProvider`. Will match match the `WaveFormat` of the input `ISampleProvider` except that it will be mono (`WaveFormat.Channels` = 1). 27 | public WaveFormat WaveFormat { get; } 28 | 29 | /// Tries to read `count` sample frames from the input `ISampleProvider`, averages the sample values across all channels and writes one mixed sample value for each sample frame into `buffer`. 30 | /// The buffer to fill with samples. 31 | /// The offset into `buffer`. 32 | /// The number of sample frames to read. 33 | /// The number of samples written to the buffer. 34 | public int Read(float[] buffer, int offset, int count) { 35 | var sourceBytesRequired = count * sourceChannels; 36 | sourceBuffer = BufferHelpers.Ensure(sourceBuffer, sourceBytesRequired); 37 | 38 | var samplesRead = sourceProvider.Read(sourceBuffer, offset * sourceChannels, sourceBytesRequired); 39 | var sampleFramesRead = samplesRead / sourceChannels; 40 | 41 | for (var sampleIndex = 0; sampleIndex < samplesRead; sampleIndex += sourceChannels) 42 | buffer[offset++] = Clamp(AverageAt(sampleIndex)); 43 | 44 | return sampleFramesRead; 45 | } 46 | 47 | private float AverageAt(int frameStart) { 48 | var mixedValue = 0.0f; 49 | 50 | for (var channel = 0; channel < sourceChannels; channel++) 51 | mixedValue += sourceBuffer[frameStart + channel]; 52 | 53 | return mixedValue / sourceChannels; 54 | } 55 | 56 | private static float Clamp(float value) => Math.Clamp(value, -1.0f, 1.0f); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/README.md: -------------------------------------------------------------------------------- 1 | # DtmfDetection.NAudio 1.2.0 API documentation 2 | 3 | Created by [mddox](https://github.com/loxsmoke/mddox) on 05/03/2020 4 | 5 | # All types 6 | 7 | | | | | 8 | |---|---|---| 9 | | [AudioFile Class](#audiofile-class) | [BufferedWaveProviderExt Class](#bufferedwaveproviderext-class) | [SampleProviderExt Class](#sampleproviderext-class) | 10 | | [AudioStream Class](#audiostream-class) | [IWaveInExt Class](#iwaveinext-class) | [WaveStreamExt Class](#wavestreamext-class) | 11 | | [BackgroundAnalyzer Class](#backgroundanalyzer-class) | [MonoSampleProvider Class](#monosampleprovider-class) | | 12 | # AudioFile Class 13 | 14 | Namespace: DtmfDetection.NAudio 15 | 16 | Convenience implementation of `ISamples` for `WaveStream` audio. `WaveStream`s are commonly used for finite data like audio files. The `Analyzer` uses `ISamples` to read the input data in blocks and feed them to the `Detector`. 17 | 18 | ## Properties 19 | 20 | | Name | Type | Summary | 21 | |---|---|---| 22 | | **Channels** | int | Returns the number of channels in the `WaveStream` input or `1` when mono-conversion has been enabled. | 23 | | **SampleRate** | int | Returns the target sample rate this `AudioFile` has been created with. | 24 | | **Position** | TimeSpan | Returns the current position of the input `WaveStream`. | 25 | ## Constructors 26 | 27 | | Name | Summary | 28 | |---|---| 29 | | **AudioFile(WaveStream source, int targetSampleRate, bool forceMono)** | Creates a new `AudioFile` from a `WaveStream` input. Also resamples the input and optionally converts it to single-channel audio. | 30 | ## Methods 31 | 32 | | Name | Returns | Summary | 33 | |---|---|---| 34 | | **Read(float[] buffer, int count)** | int | Reads `count` samples from the input and writes them into `buffer`. | 35 | # AudioStream Class 36 | 37 | Namespace: DtmfDetection.NAudio 38 | 39 | Convenience implementation of `ISamples` for an infinite `IWaveIn` audio stream. The `Analyzer` uses `ISamples` to read the input data in blocks and feed them to the `Detector`. 40 | 41 | ## Properties 42 | 43 | | Name | Type | Summary | 44 | |---|---|---| 45 | | **Channels** | int | Returns the number of channels in the `IWaveIn` input or `1` when mono-conversion has been enabled. | 46 | | **SampleRate** | int | Returns the target sample rate this `AudioStream` has been created with. | 47 | | **Position** | TimeSpan | Simply calls `DateTime.Now.TimeOfDay` and returns the result. | 48 | ## Constructors 49 | 50 | | Name | Summary | 51 | |---|---| 52 | | **AudioStream(IWaveIn source, int targetSampleRate, bool forceMono)** | Creates a new `AudioStream` from an `IWaveIn` input by buffering it with a `BufferedWaveProvider`. Also resamples the input and optionally converts it to single-channel audio. | 53 | ## Methods 54 | 55 | | Name | Returns | Summary | 56 | |---|---|---| 57 | | **Read(float[] buffer, int count)** | int | Reads `count` samples from the input and writes them into `buffer`. Will block as long as it takes for the input to buffer the requested number of samples. | 58 | | **StopWaiting()** | void | Stops waiting for the input to buffer data. Some `IWaveIn`s don't have data available continuously. For instance a `WasapiLoopbackCapture` will only have data as long as the OS is playing some audio. Calling `StopWaiting()` will break the infinite wait loop and the `Analyzer` processing this `AudioStream` will consider it being "finished". This in turn helps to gracefully exit the thread running the analysis. | 59 | # BackgroundAnalyzer Class 60 | 61 | Namespace: DtmfDetection.NAudio 62 | 63 | Helper that does audio analysis in a background thread. Useful when analyzing infinite inputs like mic-in our the current audio output. 64 | 65 | ## Constructors 66 | 67 | | Name | Summary | 68 | |---|---| 69 | | **BackgroundAnalyzer(IWaveIn source, bool forceMono, Config? config, IAnalyzer analyzer)** | Creates a new `BackgroundAnalyzer` and immediately starts listening to the `IWaveIn` input. `Dispose()` this instance to stop the background thread doing the analysis. | 70 | ## Methods 71 | 72 | | Name | Returns | Summary | 73 | |---|---|---| 74 | | **Dispose()** | void | Calls `IWaveIn.StopRecording()` on the input stream and halts the background thread doing the analysis. | 75 | # BufferedWaveProviderExt Class 76 | 77 | Namespace: DtmfDetection.NAudio 78 | 79 | Provides an extension method that waits until a `BufferedWaveProvider` has read enough data. 80 | 81 | ## Methods 82 | 83 | | Name | Returns | Summary | 84 | |---|---|---| 85 | | **WaitForSamples(BufferedWaveProvider source, int count)** | bool | Blocks the thread for as long as the `BufferedWaveProvider` minimally should need to buffer at least `count` sample frames. The wait time is estimated from the difference of the number of already buffered bytes to the number of requested bytes. | 86 | # IWaveInExt Class 87 | 88 | Namespace: DtmfDetection.NAudio 89 | 90 | Provides an extensions method to buffer a `IWaveIn` stream using a `BufferedWaveProvider`. 91 | 92 | ## Methods 93 | 94 | | Name | Returns | Summary | 95 | |---|---|---| 96 | | **ToBufferedWaveProvider(IWaveIn source)** | BufferedWaveProvider | Creates a `BufferedWaveProvider` for a `IWaveIn` and returns it. | 97 | # MonoSampleProvider Class 98 | 99 | Namespace: DtmfDetection.NAudio 100 | 101 | Decorates an `ISampleProvider` with a mono-conversion step. 102 | 103 | ## Properties 104 | 105 | | Name | Type | Summary | 106 | |---|---|---| 107 | | **WaveFormat** | WaveFormat | The `WaveFormat` of the decorated `ISampleProvider`. Will match match the `WaveFormat` of the input `ISampleProvider` except that it will be mono (`WaveFormat.Channels` = 1). | 108 | ## Constructors 109 | 110 | | Name | Summary | 111 | |---|---| 112 | | **MonoSampleProvider(ISampleProvider sourceProvider)** | Creates a new `MonoSampleProvider` from a multi-channel `ISampleProvider`. | 113 | ## Methods 114 | 115 | | Name | Returns | Summary | 116 | |---|---|---| 117 | | **Read(float[] buffer, int offset, int count)** | int | Tries to read `count` sample frames from the input `ISampleProvider`, averages the sample values across all channels and writes one mixed sample value for each sample frame into `buffer`. | 118 | # SampleProviderExt Class 119 | 120 | Namespace: DtmfDetection.NAudio 121 | 122 | Provides extensions methods for `ISampleProvider`s. 123 | 124 | ## Methods 125 | 126 | | Name | Returns | Summary | 127 | |---|---|---| 128 | | **AsMono(ISampleProvider source)** | ISampleProvider | Converts multi-channel input data to mono by avering all channels. Does nothing in case the input data already is mono. | 129 | | **Resample(ISampleProvider source, int targetSampleRate)** | ISampleProvider | Resamples the input data to the specified target sample rate using the `WdlResamplingSampleProvider`. Does nothing in case the sample rate already matches. | 130 | # WaveStreamExt Class 131 | 132 | Namespace: DtmfDetection.NAudio 133 | 134 | Provides an extension method to detect DTMF tones in a `WaveStream`. 135 | 136 | ## Methods 137 | 138 | | Name | Returns | Summary | 139 | |---|---|---| 140 | | **DtmfChanges(WaveStream waveStream, bool forceMono, Config? config)** | List\ | Detects DTMF tones in a `WaveStream`. | 141 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/SampleProviderExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using global::NAudio.Wave; 3 | using global::NAudio.Wave.SampleProviders; 4 | 5 | /// Provides extensions methods for `ISampleProvider`s. 6 | public static class SampleProviderExt { 7 | /// Resamples the input data to the specified target sample rate using the `WdlResamplingSampleProvider`. Does nothing in case the sample rate already matches. 8 | /// The `ISampleProvider` providing the source samples. 9 | /// The sample rate to convert the provided samples to. 10 | /// A new `ISampleProvider` having the specified target sample rate. 11 | public static ISampleProvider Resample(this ISampleProvider source, int targetSampleRate) 12 | => source.WaveFormat.SampleRate != targetSampleRate 13 | ? new WdlResamplingSampleProvider(source, targetSampleRate) 14 | : source; 15 | 16 | /// Converts multi-channel input data to mono by avering all channels. Does nothing in case the input data already is mono. 17 | /// The `ISampleProvider` providing the source samples. 18 | /// A new `ISampleProvider` having only one channel. 19 | public static ISampleProvider AsMono(this ISampleProvider source) 20 | => source.WaveFormat.Channels > 1 21 | ? new MonoSampleProvider(source) 22 | : source; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/WaveFormatExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System; 3 | using global::NAudio.Wave; 4 | 5 | /// Provides an extension method to convert a byte size to a duration based on a wave format. 6 | public static class WaveFormatExt { 7 | /// Gets the latency in milliseconds equivalent to the size of a wave buffer. 8 | /// The format of the wave buffer. 9 | /// The size of the buffer in bytes. 10 | /// The latency in milliseconds. 11 | public static int ConvertByteSizeToLatency(this WaveFormat waveFormat, int bytes) 12 | => (int)Math.Round(1000 / ((double)waveFormat.AverageBytesPerSecond / bytes)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/DtmfDetection.NAudio/WaveStreamExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio { 2 | using System.Collections.Generic; 3 | using global::NAudio.Wave; 4 | 5 | /// Provides an extension method to detect DTMF tones in a `WaveStream`. 6 | public static class WaveStreamExt { 7 | /// Detects DTMF tones in a `WaveStream`. 8 | /// The input audio data as a `WaveStream`. 9 | /// Toggles conversion of multi-channel audio to mono before the analysis. 10 | /// Optional detector configuration. Defaults to `Config.Default`. 11 | /// All detected DTMF tones as a list of `DtmfChange`s. 12 | public static List DtmfChanges(this WaveStream waveStream, bool forceMono = true, Config? config = null) { 13 | var cfg = config ?? Config.Default; 14 | var analyzer = Analyzer.Create( 15 | new AudioFile(waveStream, cfg.SampleRate, forceMono), 16 | cfg); 17 | 18 | var dtmfs = new List(); 19 | 20 | while (analyzer.MoreSamplesAvailable) 21 | dtmfs.AddRange(analyzer.AnalyzeNextBlock()); 22 | 23 | return dtmfs; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DtmfDetection/Analyzer.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection.Interfaces; 6 | 7 | /// The `Analyzer` reads sample blocks of size `Config.SampleBlockSize * ISamples.Channels` from the input sample data and feeds each sample block to its `IDetector` every time `AnalyzeNextBlock()` is called. An internal state machine is used to skip redundant reports of the same DTMF tone detected in consecutive sample blocks. Instead only the starting and stop position of each DMTF tone will be reported. When no more samples are available the property `MoreSamplesAvailable` will be set to `false` and the analysis is considered finished (i.e. subsequent calls to `AnalyzeNextBlock()` should be avoided as they might fail or result in undefined behavior, depending on the `ISamples` implementation. 8 | public class Analyzer : IAnalyzer { 9 | private readonly float[] buffer; 10 | private readonly ISamples samples; 11 | private readonly int blockSize; 12 | private readonly IDetector detector; 13 | private IReadOnlyList prevKeys; 14 | 15 | /// Creates a new `Analyzer` that will feed the given sample data to the given `IDetector`. 16 | /// The samples to analyze. Its number of channels must match the number of channels the `IDetector` has been created for. Its sample rate must match the sample rate of the `IDetector`s config. 17 | /// The detector to use for the analysis. 18 | public Analyzer(ISamples samples, IDetector detector) { 19 | _ = samples ?? throw new ArgumentNullException(nameof(samples)); 20 | _ = detector ?? throw new ArgumentNullException(nameof(detector)); 21 | if (samples.Channels != detector.Channels) throw new InvalidOperationException("'ISamples.Channels' does not match 'Detector.Channels'"); 22 | if (samples.SampleRate != detector.Config.SampleRate) throw new InvalidOperationException("'ISamples.SampleRate' does not match 'Detector.Config.SampleRate'"); 23 | 24 | this.samples = samples; 25 | blockSize = detector.Config.SampleBlockSize * samples.Channels; 26 | buffer = new float[blockSize]; 27 | this.detector = detector; 28 | prevKeys = Enumerable.Repeat(PhoneKey.None, samples.Channels).ToArray(); 29 | } 30 | 31 | /// Creates a new `Analyzer` that will feed the given sample data to the given `IDetector`. 32 | /// The samples to analyze. Its number of channels must match the number of channels the `IDetector` has been created for. Its sample rate must match the sample rate of the `IDetector`s config. 33 | /// The detector to use for the analysis. 34 | /// A new `Analyzer` instance. 35 | public static Analyzer Create(ISamples samples, IDetector detector) => new Analyzer(samples, detector); 36 | 37 | /// Creates a new `Analyzer` using a self-created instance of `Detector` to feed the given sample data to it. 38 | /// The samples to analyze. Its sample rate must match the sample rate of the given `Config`. 39 | /// The detector config used to create a `Detector`. 40 | /// A new `Analyzer` instance. 41 | public static Analyzer Create(ISamples samples, in Config config) { 42 | if (samples is null) throw new ArgumentNullException(nameof(samples)); 43 | if (samples.SampleRate != config.SampleRate) throw new InvalidOperationException("'ISamples.SampleRate' does not match 'Config.SampleRate'"); 44 | 45 | return new Analyzer(samples, new Detector(samples.Channels, config)); 46 | } 47 | 48 | /// Indicates whether there is more data to analyze. `AnalyzeNextBlock()` should not be called when this is `false`. Is `true` initially and turns `false` as soon as `ISamples.Read()` returned a number less than `Config.SampleBlockSize`. 49 | public bool MoreSamplesAvailable { get; private set; } = true; 50 | 51 | /// Tries to read `Config.SampleBlockSize * ISamples.Channels` samples from the input data and runs DTMF detection on that sample block. Should only be called when `MoreSamplesAvailable` is true. 52 | /// A list of the detected `DtmfChange`s representing DTMF tones that started or stopped in the analyzed sample block. 53 | public IList AnalyzeNextBlock() { 54 | var currPos = samples.Position; 55 | var n = samples.Read(buffer, blockSize); 56 | MoreSamplesAvailable = n >= blockSize; 57 | 58 | var currKeys = detector.Detect(buffer.AsSpan().Slice(0, n)); 59 | var changes = FindDtmfChanges(currKeys, prevKeys, currPos, samples.Channels); 60 | if (!MoreSamplesAvailable) changes.AddRange(FindCutOff(currKeys, samples.Position, samples.Channels)); 61 | 62 | prevKeys = currKeys; 63 | return changes; 64 | } 65 | 66 | private static List FindDtmfChanges( 67 | IReadOnlyList currKeys, 68 | IReadOnlyList prevKeys, 69 | TimeSpan currPos, 70 | int channels) { 71 | var changes = new List(2 * channels); 72 | 73 | for (var c = 0; c < channels; c++) { 74 | switch (prevKeys[c], currKeys[c]) { 75 | case (PhoneKey.None, PhoneKey.None): 76 | break; 77 | case (PhoneKey.None, var curr): 78 | changes.Add(DtmfChange.Start(curr, currPos, c)); 79 | break; 80 | case (var prev, PhoneKey.None): 81 | changes.Add(DtmfChange.Stop(prev, currPos, c)); 82 | break; 83 | case (var prev, var curr) when prev != curr: 84 | changes.Add(DtmfChange.Stop(prev, currPos, c)); 85 | changes.Add(DtmfChange.Start(curr, currPos, c)); 86 | break; 87 | } 88 | } 89 | 90 | return changes; 91 | } 92 | 93 | private static List FindCutOff( 94 | IReadOnlyList prevKeys, 95 | TimeSpan stopPos, 96 | int channels) 97 | => FindDtmfChanges( 98 | Enumerable.Repeat(PhoneKey.None, channels).ToArray(), 99 | prevKeys, 100 | stopPos, 101 | channels); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/DtmfDetection/AudioData.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using DtmfDetection.Interfaces; 4 | 5 | /// Convenience implementation of `ISamples` for PCM audio data. PCM data is usually represented as an array of `float`s. The `Analyzer` uses `ISamples` to read the input data in blocks and feed them to the `Detector`. 6 | public class AudioData : ISamples { 7 | private readonly float[] samples; 8 | private long position; 9 | 10 | /// Returns the number of channels this `AudioData` has been created with. 11 | public int Channels { get; } 12 | 13 | /// Returns the sample rate this `AudioData` has been created with. 14 | public int SampleRate { get; } 15 | 16 | /// Calculates and returns the current position in the PCM data. 17 | public TimeSpan Position => new TimeSpan((long)Math.Round(position / Channels * 1000.0 / SampleRate)); 18 | 19 | /// Creates a new `AudioData` from the given array of `float` values which were sampled with the given sample rate and for the given number of channels. 20 | /// An array of `float`s representing the PCM data. 21 | /// The number of channels in the PCM data. If this value is greater than `1` then the sample values in `samples` must be interleaved (i.e. `left sample 1, right sample 1, left sample 2, right sample 2, ...`). 22 | /// The sample rate of the PCM data in Hz. This should match the sample rate the `Analyzer` is using (via `Config.SampleRate`). 23 | public AudioData(float[] samples, int channels, int sampleRate) 24 | => (this.samples, Channels, SampleRate) = (samples, channels, sampleRate); 25 | 26 | /// Reads `count` samples from the input and writes them into `buffer`. Because the input PCM data already has the expected format, this boils down to a simple call to `Array.Copy()`. 27 | /// The output array to write the read samples to. 28 | /// The number of samples to read. 29 | /// The number of samples that have been read. Will always equal `count` except when the end of the input has been reached, in which case `Read()` returns a number less than `count`. 30 | public int Read(float[] buffer, int count) { 31 | var safeCount = (int)Math.Min(count, samples.LongLength - position); 32 | Array.Copy(samples, position, buffer, 0, safeCount); 33 | position += safeCount; 34 | return safeCount; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DtmfDetection/Config.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | 4 | /// The detector configuration. 5 | public readonly struct Config : IEquatable { 6 | /// The default detection threshold (tuned to normalized responses). 7 | public const double DefaultThreshold = 30; 8 | 9 | /// The default number of samples to analyze before the Goertzel response should be calulated (tuned to minimize error of the target frequency bin). 10 | public const int DefaultSampleBlockSize = 205; 11 | 12 | /// Default rate (in Hz) at which the analyzed samples are expected to have been measured. 13 | public const int DefaultSampleRate = 8000; 14 | 15 | /// A default configuration instance. 16 | public static readonly Config Default = new Config(DefaultThreshold, DefaultSampleBlockSize, DefaultSampleRate, normalizeResponse: true); 17 | 18 | /// The detection threshold. Typical values are `30`-`35` (when `NormalizeResponse` is `true`) and `100`-`115` (when `NormalizeResponse` is `false`). 19 | public readonly double Threshold; 20 | 21 | /// The number of samples to analyze before the Goertzel response should be calulated. It is recommened to leave it at the default value `205` (tuned to minimize error of the target frequency bin). 22 | public readonly int SampleBlockSize; 23 | 24 | /// The sample rate (in Hz) the Goertzel algorithm expects. Sources with higher samples rates must resampled to this sample rate. It is recommended to leave it at the default value `8000`. 25 | public readonly int SampleRate; 26 | 27 | /// Toggles normalization of the Goertzel response with the total signal energy of the sample block. Recommended setting is `true` as this provides invariance to loudness changes of the signal. 28 | public readonly bool NormalizeResponse; 29 | 30 | /// Creates a new `Config` instance. 31 | /// The detection threshold. Typical values are `30`-`35` (when `normalizeResponse` is `true`) and `100`-`115` (when `normalizeResponse` is `false`). 32 | /// The number of samples to analyze before the Goertzel response should be calulated. It is recommened to leave it at the default value `205` (tuned to minimize error of the target frequency bin). 33 | /// The sample rate (in Hz) the Goertzel algorithm expects. Sources with higher samples rates must resampled to this sample rate. It is recommended to leave it at the default value `8000`. 34 | /// Toggles normalization of the Goertzel response with the total signal energy of the sample block. Recommended setting is `true` as this provides invariance to loudness changes of the signal. 35 | public Config(double threshold, int sampleBlockSize, int sampleRate, bool normalizeResponse) 36 | => (Threshold, SampleBlockSize, SampleRate, NormalizeResponse) = (threshold, sampleBlockSize, sampleRate, normalizeResponse); 37 | 38 | /// Creates a cloned `Config` instance from this instance, but with a new `Threshold` setting. 39 | /// The detection threshold. Typical values are `30`-`35` (when `normalizeResponse` is `true`) and `100`-`115` (when `normalizeResponse` is `false`). 40 | /// A new `Config` instance with the specified `Threshold` setting. 41 | public Config WithThreshold(double threshold) => new Config(threshold, SampleBlockSize, SampleRate, NormalizeResponse); 42 | 43 | /// Creates a cloned `Config` instance from this instance, but with a new `SampleBlockSize` setting. 44 | /// The number of samples to analyze before the Goertzel response should be calulated. It is recommened to leave it at the default value `205` (tuned to minimize error of the target frequency bin). 45 | /// A new `Config` instance with the specified `SampleBlockSize` setting. 46 | public Config WithSampleBlockSize(int sampleBlockSize) => new Config(Threshold, sampleBlockSize, SampleRate, NormalizeResponse); 47 | 48 | /// Creates a cloned `Config` instance from this instance, but with a new `SampleRate` setting. 49 | /// The sample rate (in Hz) the Goertzel algorithm expects. Sources with higher samples rates must resampled to this sample rate. It is recommended to leave it at the default value `8000`. 50 | /// A new `Config` instance with the specified `SampleRate` setting. 51 | public Config WithSampleRate(int sampleRate) => new Config(Threshold, SampleBlockSize, sampleRate, NormalizeResponse); 52 | 53 | /// Creates a cloned `Config` instance from this instance, but with a new `NormalizeResponse` setting. 54 | /// Toggles normalization of the Goertzel response with the total signal energy of the sample block. Recommended setting is `true` as this provides invariance to loudness changes of the signal. 55 | /// A new `Config` instance with the specified `NormalizeResponse` setting. 56 | public Config WithNormalizeResponse(bool normalizeResponse) => new Config(Threshold, SampleBlockSize, SampleRate, normalizeResponse); 57 | 58 | #region Equality implementations 59 | 60 | /// Indicates whether the current `Config` is equal to another `Config`. 61 | /// A `Config` to compare with this `Config`. 62 | /// Returns `true` if the current `Config` is equal to `other`; otherwise, `false`. 63 | public bool Equals(Config other) => 64 | (Threshold, SampleBlockSize, SampleRate, NormalizeResponse) 65 | == (other.Threshold, other.SampleBlockSize, other.SampleRate, other.NormalizeResponse); 66 | 67 | /// Indicates whether this `Config` and a specified object are equal. 68 | /// The object to compare with the current `Config`. 69 | /// Returns `true` if `obj` this `Config` are the same type and represent the same value; otherwise, `false`. 70 | public override bool Equals(object? obj) => obj is Config other && Equals(other); 71 | 72 | /// Indicates whether the left-hand side `Config` is equal to the right-hand side `Config`. 73 | /// The left-hand side `Config` of the comparison. 74 | /// The right-hand side `Config` of the comparison. 75 | /// Returns `true` if the left-hand side `Config` is equal to the right-hand side `Config`; otherwise, `false`. 76 | public static bool operator ==(Config left, Config right) => left.Equals(right); 77 | 78 | /// Indicates whether the left-hand side `Config` is not equal to the right-hand side `Config`. 79 | /// The left-hand side `Config` of the comparison. 80 | /// The right-hand side `Config` of the comparison. 81 | /// Returns `true` if the left-hand side `Config` is not equal to the right-hand side `Config`; otherwise, `false`. 82 | public static bool operator !=(Config left, Config right) => !(left == right); 83 | 84 | /// Returns the hash code for this `Config`. 85 | /// A 32-bit signed integer that is the hash code for this `Config`. 86 | public override int GetHashCode() => HashCode.Combine(Threshold, SampleBlockSize, SampleRate, NormalizeResponse); 87 | 88 | #endregion Equality implementations 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/DtmfDetection/Detector.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection.Interfaces; 6 | 7 | /// Creates a `Goertzel` accumulator for each of the DTMF tone low (697, 770, 852, and 941 Hz) and high frequencies (1209, 1336, 1477, and 1633 Hz) and repeats that for each audio channel in the input data. When `Detect()` is called, each sample of the input sample block is added to each `Goertzel` accumulator and afterwards the Goertzel response of each frequency is retrieved. Reports a detected DTMF tone when exactly one of the four low frequency responses crosses the detection threshold, and exactly one of the four high frequency responses crosses the detection threshold. 8 | public class Detector : IDetector { 9 | private static readonly IReadOnlyList lowTones = new[] { 697, 770, 852, 941 }; 10 | private static readonly IReadOnlyList highTones = new[] { 1209, 1336, 1477, 1633 }; 11 | private readonly IReadOnlyList initLoGoertz; 12 | private readonly IReadOnlyList initHiGoertz; 13 | 14 | /// The number of channels this detector has been created for. Used by the `Analyzer` to validate that this detector supports the number of channels present int the source data (`ISamples.Channels`). 15 | public int Channels { get; } 16 | 17 | /// The `Config` this detector has been created with. 18 | public Config Config { get; } 19 | 20 | /// Creates a new `Detector` for the given number of audio channels and with the given dector config. 21 | /// The number of channels in the input sample data. 22 | /// The detector config. 23 | public Detector(int channels, in Config config) { 24 | Channels = channels; 25 | Config = config; 26 | 27 | var sampleRate = config.SampleRate; 28 | var numSamples = config.SampleBlockSize; 29 | initLoGoertz = lowTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 30 | initHiGoertz = highTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 31 | } 32 | 33 | /// Runs the Goertzel algorithm on all samples in `sampleBlock` and returns the DTMF key detected in each channel. `PhoneKey.None` is used in case no DTMF key has been detected in a channel. 34 | /// The block of samples to analyze. Its length should always match `Config.SampleBlockSize * Detector.Channels` except when the end of the input has been reached, in which case it might be smalller once. 35 | /// A list of DTMF keys, one for each channel. Hence its length will match the value of `Detector.Channels`. 36 | public IReadOnlyList Detect(in ReadOnlySpan sampleBlock) { 37 | var loGoertz = CreateGoertzels(initLoGoertz, Channels); 38 | var hiGoertz = CreateGoertzels(initHiGoertz, Channels); 39 | AddSamples(sampleBlock, Channels, loGoertz, hiGoertz); 40 | return Detect(loGoertz, hiGoertz, Config.Threshold, Channels); 41 | } 42 | 43 | private static Goertzel[][] CreateGoertzels(IReadOnlyList initGoertz, int channels) { 44 | var goertz = new Goertzel[channels][]; 45 | 46 | for (var c = 0; c < channels; c++) { 47 | goertz[c] = new[] { initGoertz[0], initGoertz[1], initGoertz[2], initGoertz[3] }; 48 | } 49 | 50 | return goertz; 51 | } 52 | 53 | private static void AddSamples(in ReadOnlySpan sampleBlock, int channels, Goertzel[][] loGoertz, Goertzel[][] hiGoertz) { 54 | for (var i = 0; i < sampleBlock.Length; i++) { 55 | var c = i % channels; 56 | 57 | loGoertz[c][0] = loGoertz[c][0].AddSample(sampleBlock[i]); 58 | loGoertz[c][1] = loGoertz[c][1].AddSample(sampleBlock[i]); 59 | loGoertz[c][2] = loGoertz[c][2].AddSample(sampleBlock[i]); 60 | loGoertz[c][3] = loGoertz[c][3].AddSample(sampleBlock[i]); 61 | 62 | hiGoertz[c][0] = hiGoertz[c][0].AddSample(sampleBlock[i]); 63 | hiGoertz[c][1] = hiGoertz[c][1].AddSample(sampleBlock[i]); 64 | hiGoertz[c][2] = hiGoertz[c][2].AddSample(sampleBlock[i]); 65 | hiGoertz[c][3] = hiGoertz[c][3].AddSample(sampleBlock[i]); 66 | } 67 | } 68 | 69 | private PhoneKey[] Detect( 70 | IReadOnlyList> loGoertz, 71 | IReadOnlyList> hiGoertz, 72 | double threshold, 73 | int channels) { 74 | var phoneKeys = new PhoneKey[channels]; 75 | 76 | for (var c = 0; c < channels; c++) { 77 | phoneKeys[c] = Detect(loGoertz[c], hiGoertz[c], threshold); 78 | } 79 | 80 | return phoneKeys; 81 | } 82 | 83 | private PhoneKey Detect(IReadOnlyList loGoertz, IReadOnlyList hiGoertz, double threshold) { 84 | var (fstLoIdx, sndLoIdx) = FindMaxTwo(loGoertz); 85 | var (fstLoVal, sndLoVal) = (Response(loGoertz[fstLoIdx]), Response(loGoertz[sndLoIdx])); 86 | 87 | var (fstHiIdx, sndHiIdx) = FindMaxTwo(hiGoertz); 88 | var (fstHiVal, sndHiVal) = (Response(hiGoertz[fstHiIdx]), Response(hiGoertz[sndHiIdx])); 89 | 90 | //Console.WriteLine($"lo: {fstLoIdx}: {fstLoVal,8:N3}, {sndLoIdx}: {sndLoVal,8:N3} | hi: {fstHiIdx}: {fstHiVal,8:N3}, {sndHiIdx}: {sndHiVal,8:N3}"); 91 | 92 | return fstLoVal < threshold || fstHiVal < threshold 93 | || fstLoVal > threshold && sndLoVal > threshold 94 | || fstHiVal > threshold && sndHiVal > threshold 95 | || double.IsNaN(fstLoVal) || double.IsNaN(fstHiVal) 96 | ? PhoneKey.None 97 | : (highTones[fstHiIdx], lowTones[fstLoIdx]).ToPhoneKey(); 98 | } 99 | 100 | private (int fstIdx, int sndIdx) FindMaxTwo(IReadOnlyList goertz) { 101 | int fst = 0, snd = 1; 102 | 103 | if (Response(goertz[1]) > Response(goertz[0])) { 104 | snd = 0; 105 | fst = 1; 106 | } 107 | 108 | if (Response(goertz[2]) > Response(goertz[fst])) { 109 | snd = fst; 110 | fst = 2; 111 | } else if (Response(goertz[2]) > Response(goertz[snd])) { 112 | snd = 2; 113 | } 114 | 115 | if (Response(goertz[3]) > Response(goertz[fst])) { 116 | snd = fst; 117 | fst = 3; 118 | } else if (Response(goertz[3]) > Response(goertz[snd])) { 119 | snd = 3; 120 | } 121 | 122 | return (fst, snd); 123 | } 124 | 125 | private double Response(in Goertzel g) => Config.NormalizeResponse ? g.NormResponse : g.Response; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/DtmfDetection/DtmfChange.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | 4 | /// Represents the start or a stop of a DTMF tone in audio data. 5 | public readonly struct DtmfChange : IEquatable { 6 | /// The key of the DTMF tone that changed. 7 | public readonly PhoneKey Key; 8 | 9 | /// The position inside the audio data where the change was detected. 10 | public readonly TimeSpan Position; 11 | 12 | /// The audio channel where the change was detected. 13 | public readonly int Channel; 14 | 15 | /// Indicates whether a DMTF tone started or stopped at the current position. 16 | public readonly bool IsStart; 17 | 18 | /// Indicates whether a DMTF tone started or stopped at the current position. 19 | public readonly bool IsStop => !IsStart; 20 | 21 | /// Creates a new `DtmfChange` with the given identification and location. 22 | /// The key of the DTMF tone. 23 | /// The position of the DTMF tone inside the audio data. 24 | /// The audio channel of the DTMF tone. 25 | /// Indicates whether a DMTF tone started or stopped at the current position. 26 | public DtmfChange(PhoneKey key, TimeSpan position, int channel, bool isStart) 27 | => (Key, Position, Channel, IsStart) = (key, position, channel, isStart); 28 | 29 | /// Creates a new `DtmfChange` that marks the start of a DTMF tone at the specified location. 30 | /// The key of the DTMF tone. 31 | /// The position of the DTMF tone inside the audio data. 32 | /// The audio channel of the DTMF tone. 33 | /// A new `DtmfChange` marking the start of a DTMF tone. 34 | public static DtmfChange Start(PhoneKey key, TimeSpan position, int channel) 35 | => new DtmfChange(key, position, channel, isStart: true); 36 | 37 | 38 | /// Creates a new `DtmfChange` that marks the end of a DTMF tone at the specified location. 39 | /// The key of the DTMF tone. 40 | /// The position of the DTMF tone inside the audio data. 41 | /// The audio channel of the DTMF tone. 42 | /// A new `DtmfChange` marking the end of a DTMF tone. 43 | public static DtmfChange Stop(PhoneKey key, TimeSpan position, int channel) 44 | => new DtmfChange(key, position, channel, isStart: false); 45 | 46 | /// Prints the identification and location of this `DtmfChange` to a `string` and returns it. 47 | /// A `string` identifiying and localizing this `DtmfChange`. 48 | public override string ToString() => $"{Key.ToSymbol()} {(IsStart ? "started" : "stopped")} @ {Position} (ch: {Channel})"; 49 | 50 | #region Equality implementations 51 | 52 | /// Indicates whether the current `DtmfChange` is equal to another `DtmfChange`. 53 | /// A `DtmfChange` to compare with this `DtmfChange`. 54 | /// Returns `true` if the current `DtmfChange` is equal to `other`; otherwise, `false`. 55 | public bool Equals(DtmfChange other) 56 | => (Key, Position, Channel, IsStart) == (other.Key, other.Position, other.Channel, other.IsStart); 57 | 58 | /// Indicates whether this `DtmfChange` and a specified object are equal. 59 | /// The object to compare with the current `DtmfChange`. 60 | /// Returns `true` if `obj` this `DtmfChange` are the same type and represent the same value; otherwise, `false`. 61 | public override bool Equals(object? obj) => obj is DtmfChange other && Equals(other); 62 | 63 | /// Indicates whether the left-hand side `DtmfChange` is equal to the right-hand side `DtmfChange`. 64 | /// The left-hand side `DtmfChange` of the comparison. 65 | /// The right-hand side `DtmfChange` of the comparison. 66 | /// Returns `true` if the left-hand side `DtmfChange` is equal to the right-hand side `DtmfChange`; otherwise, `false`. 67 | public static bool operator ==(DtmfChange left, DtmfChange right) => left.Equals(right); 68 | 69 | /// Indicates whether the left-hand side `DtmfChange` is not equal to the right-hand side `DtmfChange`. 70 | /// The left-hand side `DtmfChange` of the comparison. 71 | /// The right-hand side `DtmfChange` of the comparison. 72 | /// Returns `true` if the left-hand side `DtmfChange` is not equal to the right-hand side `DtmfChange`; otherwise, `false`. 73 | public static bool operator !=(DtmfChange left, DtmfChange right) => !(left == right); 74 | 75 | /// Returns the hash code for this `DtmfChange`. 76 | /// A 32-bit signed integer that is the hash code for this `DtmfChange`. 77 | public override int GetHashCode() => HashCode.Combine(Key, Position, Channel, IsStart); 78 | 79 | #endregion Equality implementations 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/DtmfDetection/DtmfDetection.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | enable 6 | .\obj\DtmfDetection.xml 7 | 8 | 9 | 10 | true 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/DtmfDetection/DtmfGenerator.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection.Interfaces; 6 | 7 | /// Provides helpers to generate DTMF tones. 8 | public static class DtmfGenerator { 9 | /// Generates single-channel PCM data playing the DTMF tone `key` infinitely. 10 | /// The DTMF tone to generate. 11 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 12 | /// An infinite sequence of PCM data playing the specified DMTF tone. 13 | public static IEnumerable Generate(PhoneKey key, int sampleRate = Config.DefaultSampleRate) 14 | => Generate(key.ToDtmfTone(), sampleRate); 15 | 16 | /// Generates single-channel PCM data playing the dual tone comprised of the two frequencies `highFreq` and `lowFreq` infinitely. 17 | /// The high frequency part of the dual tone. 18 | /// The low frequency part of the dual tone. 19 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 20 | /// An infinite sequence of PCM data playing the specified dual tone. 21 | public static IEnumerable Generate(int highFreq, int lowFreq, int sampleRate = Config.DefaultSampleRate) 22 | => Sine(highFreq, sampleRate).Add(Sine(lowFreq, sampleRate)).Normalize(1); 23 | 24 | /// Generates single-channel PCM data playing the dual tone comprised of the two frequencies `highFreq` and `lowFreq` infinitely. 25 | /// A tuple holding the high and low frequency. 26 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 27 | /// An infinite sequence of PCM data playing the specified dual tone. 28 | public static IEnumerable Generate((int highFreq, int lowFreq) dual, int sampleRate = Config.DefaultSampleRate) 29 | => Generate(dual.highFreq, dual.lowFreq, sampleRate); 30 | 31 | /// Generates single-channel PCM data playing the DTMF tone `key` for the specified length `ms`. 32 | /// The DTMF tone to generate. 33 | /// The length of the DTMF tone in milliseconds. 34 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 35 | /// A sequence of PCM data playing the specified DTMF tone. 36 | public static IEnumerable Mark(PhoneKey key, int ms = 40, int sampleRate = Config.DefaultSampleRate) 37 | => Generate(key, sampleRate).Take(NumSamples(ms, channels: 1, sampleRate)); 38 | 39 | /// Generates single-channel PCM data playing silence for the specified length `ms`. 40 | /// The length of the silence in milliseconds. 41 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 42 | /// A sequence of silent PCM data. 43 | public static IEnumerable Space(int ms = 20, int sampleRate = Config.DefaultSampleRate) 44 | => Constant(.0f).Take(NumSamples(ms, channels: 1, sampleRate)); 45 | 46 | /// Takes two sequences of single-channel PCM data and interleaves them to form a single sequence of dual-channel PCM data. 47 | /// The PCM data for the left channel. 48 | /// The PCM data for the right channel. 49 | /// A sequence of dual-channel PCM data. 50 | public static IEnumerable Stereo(IEnumerable left, IEnumerable right) 51 | => left.Zip(right, (l, r) => new[] { l, r }).SelectMany(x => x); 52 | 53 | /// Generates a sinusoidal PCM signal of infinite length for the specified frequency. 54 | /// The frequency of the signal. 55 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 56 | /// Optional amplitude of the signal. Defaults to `1`. 57 | /// An infinite sine signal. 58 | public static IEnumerable Sine(int freq, int sampleRate = Config.DefaultSampleRate, float amplitude = 1) { 59 | for (var t = 0.0; ; t += 1.0 / sampleRate) 60 | yield return (float)(amplitude * Math.Sin(2.0 * Math.PI * freq * t)); 61 | } 62 | 63 | /// Generates a constant PCM signal of infinite length. 64 | /// The amplitude of the signal. 65 | /// An infinite constant signal. 66 | public static IEnumerable Constant(float amplitude) { 67 | while (true) yield return amplitude; 68 | } 69 | 70 | /// Generates an infinite PCM signal of pseudo-random white noise. 71 | /// The amplitude of the noise. 72 | /// An infinite noise signal. 73 | public static IEnumerable Noise(float amplitude) { 74 | var rng = new Random(); 75 | while (true) { 76 | var n = amplitude * rng.NextDouble(); 77 | var sign = Math.Pow(-1, rng.Next(2)); 78 | yield return (float)(sign * n); 79 | } 80 | } 81 | 82 | /// Creates an `AudioData` instance from a sequence of PCM samples. 83 | /// The input PCM data. 84 | /// Optional number of channels in the PCM data. Defaults to `1`. 85 | /// Optional sample rate of the PCM data. Defaults to `Config.DefaultSampleRate`. 86 | /// A new `AudioData` instane that can be analyzed by the `Analyzer`. 87 | public static ISamples AsSamples( 88 | this IEnumerable source, 89 | int channels = 1, 90 | int sampleRate = Config.DefaultSampleRate) 91 | => new AudioData(source.ToArray(), channels, sampleRate); 92 | 93 | /// Adds two sequences of PCM data together. Used to generate dual tones. The amplitude might exceed the range `[-1..1]` after adding. 94 | /// One of the two input signals to add. 95 | /// One of the two input signals to add. 96 | /// The sum of both input signals. 97 | public static IEnumerable Add(this IEnumerable xs, IEnumerable ys) 98 | => xs.Zip(ys, (l, r) => l + r); 99 | 100 | /// Normlizes a signal with the given `maxAmplitude`. 101 | /// The signal to normalize. 102 | /// The value to normalize by. Ideally it should equal `Math.Abs(source.Max())`. 103 | /// The input signal with each sample value divided by `maxAmplitude`. 104 | public static IEnumerable Normalize(this IEnumerable source, float maxAmplitude) 105 | => source.Select(x => x / maxAmplitude); 106 | 107 | /// Concatenates multiple finite sequences of PCM data. Typically used with `Mark()` and `Space()`. 108 | /// The sequences to concatenate. 109 | /// The single sequence that is the concatenation of the given sequences. 110 | public static IEnumerable Concat(params IEnumerable[] xss) => xss.SelectMany(xs => xs); 111 | 112 | /// Converts a duration in milliseconds into the number of samples required to represent a signal of that duration as PCM audio data. 113 | /// The duration of the signal. 114 | /// Optional number of channels in the signal. Defaults to `1`. 115 | /// Optional sample rate of the signal. Defaults to `Config.DefaultSampleRate`. 116 | /// The number of samples needed for the specified length, channels, and sample rate. 117 | public static int NumSamples(int milliSeconds, int channels = 1, int sampleRate = Config.DefaultSampleRate) 118 | => channels * (int)Math.Round(milliSeconds / (1.0 / sampleRate * 1000)); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/DtmfDetection/DtmfTone.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | 4 | /// Represents a DTMF tone in audio data. 5 | public readonly struct DtmfTone: IEquatable { 6 | /// The key of the DTMF tone. 7 | public readonly PhoneKey Key; 8 | 9 | /// The position inside the audio data where the DTMF tone was detected. 10 | public readonly TimeSpan Position; 11 | 12 | /// The length of the DTMF tone inside the audio data. 13 | public readonly TimeSpan Duration; 14 | 15 | /// The audio channel where the DTMF tone was detected. 16 | public readonly int Channel; 17 | 18 | /// Creates a new `DtmfTone` with the given identification and location. 19 | /// The key of the DTMF tone. 20 | /// The position of the DTMF tone inside the audio data. 21 | /// The length of the DTMF tone. 22 | /// The audio channel of the DTMF tone. 23 | public DtmfTone(PhoneKey key, TimeSpan position, TimeSpan duration, int channel) 24 | => (Key, Position, Duration, Channel) = (key, position, duration, channel); 25 | 26 | /// Creates a new `DtmfTone` from two `DtmfChange`s representing the start and end of the same tone. 27 | /// The `DtmfChange` that marks the start of the DTMF tone. 28 | /// The `DtmfChange` that marks the end of the DTMF tone. 29 | /// A new `DtmfTone` that represents both `DtmfChange`s as one data structure. 30 | public static DtmfTone From(in DtmfChange start, in DtmfChange stop) => new DtmfTone( 31 | start.Key, 32 | start.Position, 33 | stop.Position - start.Position, 34 | start.Channel); 35 | 36 | /// Prints the identification and location of this `DtmfTone` to a `string` and returns it. 37 | /// A `string` identifiying and localizing this `DtmfTone`. 38 | public override string ToString() => $"{Key.ToSymbol()} @ {Position} (len: {Duration}, ch: {Channel})"; 39 | 40 | #region Equality implementations 41 | 42 | /// Indicates whether the current `DtmfTone` is equal to another `DtmfTone`. 43 | /// A `DtmfTone` to compare with this `DtmfTone`. 44 | /// Returns `true` if the current `DtmfTone` is equal to `other`; otherwise, `false`. 45 | public bool Equals(DtmfTone other) 46 | => (Key, Position, Duration, Channel) == (other.Key, other.Position, other.Duration, other.Channel); 47 | 48 | /// Indicates whether this `DtmfTone` and a specified object are equal. 49 | /// The object to compare with the current `DtmfTone`. 50 | /// Returns `true` if `obj` this `DtmfTone` are the same type and represent the same value; otherwise, `false`. 51 | public override bool Equals(object? obj) => obj is DtmfTone other && Equals(other); 52 | 53 | /// Indicates whether the left-hand side `DtmfTone` is equal to the right-hand side `DtmfTone`. 54 | /// The left-hand side `DtmfTone` of the comparison. 55 | /// The right-hand side `DtmfTone` of the comparison. 56 | /// Returns `true` if the left-hand side `DtmfTone` is equal to the right-hand side `DtmfTone`; otherwise, `false`. 57 | public static bool operator ==(DtmfTone left, DtmfTone right) => left.Equals(right); 58 | 59 | /// Indicates whether the left-hand side `DtmfTone` is not equal to the right-hand side `DtmfTone`. 60 | /// The left-hand side `DtmfTone` of the comparison. 61 | /// The right-hand side `DtmfTone` of the comparison. 62 | /// Returns `true` if the left-hand side `DtmfTone` is not equal to the right-hand side `DtmfTone`; otherwise, `false`. 63 | public static bool operator !=(DtmfTone left, DtmfTone right) => !(left == right); 64 | 65 | /// Returns the hash code for this `DtmfTone`. 66 | /// A 32-bit signed integer that is the hash code for this `DtmfTone`. 67 | public override int GetHashCode() => HashCode.Combine(Key, Position, Duration, Channel); 68 | 69 | #endregion Equality implementations 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/DtmfDetection/FloatArrayExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System.Collections.Generic; 3 | 4 | /// Provides an extension method to detect DTMF tones in PCM audio data. 5 | public static class FloatArrayExt { 6 | /// Detects DTMF tones in an array of `float`s. 7 | /// The input audio data as an array of `float` values. 8 | /// The number of audio channels in the input data. 9 | /// The sample rate (in Hz) at which the input data was sampled. 10 | /// Optional detector configuration. Defaults to `Config.Default`. 11 | /// All detected DTMF tones as a list of `DtmfChange`s. 12 | public static List DtmfChanges( 13 | this float[] samples, 14 | int channels = 1, 15 | int sampleRate = Config.DefaultSampleRate, 16 | Config? config = null) { 17 | var cfg = config ?? Config.Default; 18 | var audio = new AudioData(samples, channels, sampleRate); 19 | var analyzer = Analyzer.Create(audio, cfg); 20 | 21 | var dtmfs = new List(); 22 | 23 | while (analyzer.MoreSamplesAvailable) 24 | dtmfs.AddRange(analyzer.AnalyzeNextBlock()); 25 | 26 | return dtmfs; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DtmfDetection/Goertzel.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Globalization; 4 | 5 | /// The actual implementation of the Goertzel algorithm (https://en.wikipedia.org/wiki/Goertzel_algorithm) that estimates the strength of a frequency in a signal. It works similar to a Fourier transform except that it doesn't analyze the whole spectrum, but only a single frequency. 6 | public readonly struct Goertzel : IEquatable { 7 | /// Stores a pre-computed coefficient calculated from the parameters of `Init()`. 8 | public readonly double C; 9 | 10 | /// Stores the state of the `Goertzel`. Used to determine the strength of the target frequency in the signal. 11 | public readonly double S1, S2; 12 | 13 | /// Accumulates the total signal energy of the signal. Used for normalization. 14 | public readonly double E; 15 | 16 | /// Used to create a new `Goertzel` from the values of a previous one. 17 | /// The pre-computed coefficient. 18 | /// The `Goertzel` state. 19 | /// The `Goertzel` state. 20 | /// The total signal energy accumulated so far. 21 | public Goertzel(double c, double s1, double s2, double e) => (C, S1, S2, E) = (c, s1, s2, e); 22 | 23 | /// Initializes a `Goertzel` for a given target frequency. 24 | /// The target frequency to estimate the strength for in a signal. 25 | /// The sample rate of the signal. A rate of `8000` (Hz) is recommended. 26 | /// The number of samples that will be added to the `Goertzel` before `Response` or `NormResponse` are queried. It is recommended to use a value of `205` as this minimizes errors. 27 | /// A new `Goertzel` with a pre-computed coefficient. 28 | public static Goertzel Init(int targetFreq, int sampleRate, int numSamples) { 29 | var k = Math.Round((double)targetFreq / sampleRate * numSamples); 30 | var c = 2.0 * Math.Cos(2.0 * Math.PI * k / numSamples); 31 | return new Goertzel(c, .0, .0, .0); 32 | } 33 | 34 | /// Calculates and returns the estimated strength of the frequency in the samples given so far. 35 | public double Response => S1 * S1 + S2 * S2 - S1 * S2 * C; 36 | 37 | /// Calculates `Response`, but normalized with the total signal energy, which achieves loudness invariance. 38 | public double NormResponse => Response / E; 39 | 40 | /// Adds a new sample to this `Goertzel` and returns a new one created from the previous `Goertzel` values and the sample. 41 | /// The sample value to add. 42 | /// A new `Goertzel` that has the sample value added to this one. 43 | public Goertzel AddSample(float sample) => new Goertzel( 44 | c: C, 45 | s1: sample + C * S1 - S2, 46 | s2: S1, 47 | e: E + sample * sample); 48 | 49 | /// Creates a new `Goertzel` from this one's coefficient `C`, but resets the state (`S1`, `S2`) and the total signal energy (`E`) to `0`. Useful to save the computation of `C` when the parameters of `Init()` were to stay the same. 50 | /// A new `Goertzel` with this one's value of `C`. 51 | public Goertzel Reset() => new Goertzel(c: C, s1: 0, s2: 0, e: 0); 52 | 53 | /// Prints the value of `NormResponse` to a `string` and returns it. 54 | /// The `NormResponse` of this `Goertzel` as a `string`. 55 | public override string ToString() => NormResponse.ToString(CultureInfo.InvariantCulture); 56 | 57 | #region Equality implementations 58 | 59 | /// Indicates whether the current `Goertzel` is equal to another `Goertzel`. 60 | /// A `Goertzel` to compare with this `Goertzel`. 61 | /// Returns `true` if the current `Goertzel` is equal to `other`; otherwise, `false`. 62 | public bool Equals(Goertzel other) => (C, S1, S2, E) == (other.C, other.S1, other.S2, other.E); 63 | 64 | /// Indicates whether this `Goertzel` and a specified object are equal. 65 | /// The object to compare with the current `Goertzel`. 66 | /// Returns `true` if `obj` this `Goertzel` are the same type and represent the same value; otherwise, `false`. 67 | public override bool Equals(object? obj) => obj is Goertzel other && Equals(other); 68 | 69 | /// Indicates whether the left-hand side `Goertzel` is equal to the right-hand side `Goertzel`. 70 | /// The left-hand side `Goertzel` of the comparison. 71 | /// The right-hand side `Goertzel` of the comparison. 72 | /// Returns `true` if the left-hand side `Goertzel` is equal to the right-hand side `Goertzel`; otherwise, `false`. 73 | public static bool operator ==(Goertzel left, Goertzel right) => left.Equals(right); 74 | 75 | /// Indicates whether the left-hand side `Goertzel` is not equal to the right-hand side `Goertzel`. 76 | /// The left-hand side `Goertzel` of the comparison. 77 | /// The right-hand side `Goertzel` of the comparison. 78 | /// Returns `true` if the left-hand side `Goertzel` is not equal to the right-hand side `Goertzel`; otherwise, `false`. 79 | public static bool operator !=(Goertzel left, Goertzel right) => !(left == right); 80 | 81 | /// Returns the hash code for this `Goertzel`. 82 | /// A 32-bit signed integer that is the hash code for this `Goertzel`. 83 | public override int GetHashCode() => HashCode.Combine(C, S1, S2, E); 84 | 85 | #endregion Equality implementations 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/DtmfDetection/Interfaces/IAnalyzer.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.Interfaces { 2 | using System.Collections.Generic; 3 | 4 | /// Interface to decouple the `BackgroundAnalyzer` from the `Analyzer` it is using by default. Use this if you want to inject your own analyzer into the `BackgroundAnalyzer`. Feel free to start by copying the original `Analyzer` and adjust it to your needs. 5 | public interface IAnalyzer { 6 | /// Indicates whether there is more data to analyze. Should always be `true` initially and once it turned `false`, it should never turn back to `true` again. 7 | bool MoreSamplesAvailable { get; } 8 | 9 | /// Analyzes the next block of samples. The size of the analyzed block should match `Config.SampleBlockSize` multiplied by the number of channels in the sample data. This might throw when called while `MoreSamplesAvailable` is `false`. 10 | /// A list of detected DTMF changes. 11 | IList AnalyzeNextBlock(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/DtmfDetection/Interfaces/IDetector.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.Interfaces { 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | /// Interface to decouple the `Analyzer` from the `Detector` it is using by default. Use this if you want to inject your own detector into the `Analyzer`. Feel free to start by copying the original `Detector` and adjust it to your needs. 6 | public interface IDetector { 7 | /// The number of channels this detector has been created for. Used by the `Analyzer` to validate that this detector supports the number of channels present int the source data (`ISamples.Channels`). 8 | int Channels { get; } 9 | 10 | /// The `Config` this detector has been created with. 11 | Config Config { get; } 12 | 13 | /// Runs the Goertzel algorithm on all samples in `sampleBlock` and returns the DTMF key detected in each channel. `PhoneKey.None` is used in case no DTMF key has been detected in a channel. 14 | /// The block of samples to analyze. Its length should always match `Config.SampleBlockSize` except when the end of the input has been reached, in which case it might be smalller once. 15 | /// A list of DTMF keys, one for each channel. Hence its length must match the value of `Channels`. 16 | IReadOnlyList Detect(in ReadOnlySpan sampleBlock); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/DtmfDetection/Interfaces/ISamples.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.Interfaces { 2 | using System; 3 | 4 | /// Interface used by the `Analyzer` to access a variety of audio sources in a uniform way. Implement this interface if your data source does not fit any of the pre-built implementations: `AudioData` for float arrays of PCM data, `AudioFile` for audio files (mp3, wav, aiff and Windows Media Foundation formats), or `AudioStream` for infinite audio streams. 5 | public interface ISamples { 6 | /// The number of audio channels. This should match the value of `Config.Channels` the `Analyzer` is using. 7 | int Channels { get; } 8 | 9 | /// The rate at which the values have been sampled in Hz. This should match the value of `Config.SampleRate` the `Analyzer` is using. 10 | int SampleRate { get; } 11 | 12 | /// The position of the "read cursor" in the sample stream. This should increase with every call to `Read()` that returns a value greater than 0. 13 | TimeSpan Position { get; } 14 | 15 | /// Reads `count` samples from the input and writes them into `buffer`. This method should either block until `count` samples have been succesfully read, or return a number less than `count` to indicate that the end of the stream has been reached. Once the end of stream has been reached, subsequent calls to `Read()` are allowed to fail with exceptions. 16 | /// The output array to write the read samples to. 17 | /// The number of samples to read. 18 | /// The number of samples that have been read. Will always equal `count` except when the end of the input has been reached, in which case `Read()` returns a number less than `count`. 19 | int Read(float[] buffer, int count); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/DtmfDetection/PhoneKey.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | /// An enumeration of all possible DTMF keys. 3 | public enum PhoneKey { 4 | /// Used to represent the absence of any DTMF tones. 5 | None = -1, 6 | 7 | /// Key '0' 8 | Zero = 0, 9 | 10 | /// Key '1' 11 | One = 1, 12 | 13 | /// Key '2' 14 | Two = 2, 15 | 16 | /// Key '3' 17 | Three = 3, 18 | 19 | /// Key '4' 20 | Four = 4, 21 | 22 | /// Key '5' 23 | Five = 5, 24 | 25 | /// Key '6' 26 | Six = 6, 27 | 28 | /// Key '7' 29 | Seven = 7, 30 | 31 | /// Key '8' 32 | Eight = 8, 33 | 34 | /// Key '9' 35 | Nine = 9, 36 | 37 | /// Key '*' 38 | Star = 34, 39 | 40 | /// Key '#' 41 | Hash = 35, 42 | 43 | /// Key 'A' 44 | A = 65, 45 | 46 | /// Key 'B' 47 | B = 66, 48 | 49 | /// Key 'C' 50 | C = 67, 51 | 52 | /// Key 'D' 53 | D = 68 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/DtmfDetection/ToDtmfTonesExt.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | /// Provides helpers to generate a sequence of `DtmfTone`s from a list of `DtmfChange`s. 7 | public static class ToDtmfTonesExt { 8 | /// Converts a list of `DtmfChange`s to a sequence of `DtmfTone`s by finding the matching stop of a DTMF tones to each start of a DTMF tone and merging both into a single `DtmfTone` struct. 9 | /// A list of `DtmfChange`s ordered by `DtmfChange.Position` in ascending order. The list must be consistent (i.e. there must be a "DTMF stop" after every "DTMF start" somehwere in the list) otherwise an `InvalidOperationException` will be thrown. 10 | /// A sequence of `DtmfTone`s orderd by `DtmfTone.Position` in ascending order. 11 | public static IEnumerable ToDtmfTones(this IList dtmfs) => dtmfs 12 | .Select((dtmf, idx) => (dtmf, idx)) 13 | .Where(x => x.dtmf.IsStart) 14 | .Select(x => (start: x.dtmf, stop: dtmfs.FindMatchingStop(offset: x.idx + 1, x.dtmf))) 15 | .Select(x => DtmfTone.From(x.start, x.stop)); 16 | 17 | /// Finds the stop of a DTMF tone matching the given start of a DTMF tone in a list of `DtmfChange`s. A `DtmfChange x` matches `start` when: `x.IsStop == true`, `x.Channel == start.Channel`,`x.Key == start.Key`, and `x.Position >= start.Position`. 18 | /// The list of `DtmfChange`s to search in. Should be ordered by `DtmfChange.Position` in ascending order. 19 | /// An offset into the list to start searching from. Useful for optimizing performance. 20 | /// The DTMF start to find a matching stop for. 21 | /// The found stop of the DTMF tone. Throws an `InvalidOperationException` if no matching stop could be found. 22 | public static DtmfChange FindMatchingStop(this IList dtmfs, int offset, in DtmfChange start) => dtmfs 23 | .Find(offset, IsStopOf(start)) 24 | ?? throw new InvalidOperationException($"Inconsistent input list. Unable to find end of DTMF tone (start: {start})."); 25 | 26 | private static Predicate IsStopOf(in DtmfChange start) { 27 | var startKey = start.Key; 28 | var startCh = start.Channel; 29 | var startPos = start.Position; 30 | return stop => stop.IsStop 31 | && stop.Channel == startCh 32 | && stop.Key == startKey 33 | && stop.Position >= startPos; 34 | } 35 | 36 | private static T? Find(this IList xs, int offset, Predicate p) where T : struct { 37 | for (var i = offset; i < xs.Count; i++) { 38 | if (p(xs[i])) return xs[i]; 39 | } 40 | 41 | return null; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/DtmfDetection/Utils.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | /// Provides helpers to convert between `PhoneKey`s and their corresponding frequency encodings. 7 | public static class Utils { 8 | /// Enumerates all `PhoneKey`s except `PhoneKey.None`. 9 | /// An enumeration of all valid `PhoneKey`s. 10 | public static IEnumerable PhoneKeys() => Enum 11 | .GetValues(typeof(PhoneKey)) 12 | .Cast() 13 | .Where(k => k != PhoneKey.None); 14 | 15 | /// Converts a frequency tuple to a `PhoneKey`. 16 | /// The high and low frequencies as a `ValueTuple`. 17 | /// The matching `PhoneKey` or `PhoneKey.None` in case the given frequencies don't encode a DTMF key. 18 | public static PhoneKey ToPhoneKey(this in (int high, int low) dtmfTone) => dtmfTone switch { 19 | (1336, 941) => PhoneKey.Zero, 20 | (1209, 697) => PhoneKey.One, 21 | (1336, 697) => PhoneKey.Two, 22 | (1477, 697) => PhoneKey.Three, 23 | (1209, 770) => PhoneKey.Four, 24 | (1336, 770) => PhoneKey.Five, 25 | (1477, 770) => PhoneKey.Six, 26 | (1209, 852) => PhoneKey.Seven, 27 | (1336, 852) => PhoneKey.Eight, 28 | (1477, 852) => PhoneKey.Nine, 29 | (1209, 941) => PhoneKey.Star, 30 | (1477, 941) => PhoneKey.Hash, 31 | (1633, 697) => PhoneKey.A, 32 | (1633, 770) => PhoneKey.B, 33 | (1633, 852) => PhoneKey.C, 34 | (1633, 941) => PhoneKey.D, 35 | _ => PhoneKey.None 36 | }; 37 | 38 | /// Converts a `PhoneKey` to the two frequencies it is encoded with in audio data. 39 | /// The key to convert. 40 | /// A `ValueTuple` holding the key's high frequency in the first position and its low frequency in the second position. 41 | public static (int high, int low) ToDtmfTone(this PhoneKey key) => key switch { 42 | PhoneKey.Zero => (1336, 941), 43 | PhoneKey.One => (1209, 697), 44 | PhoneKey.Two => (1336, 697), 45 | PhoneKey.Three => (1477, 697), 46 | PhoneKey.Four => (1209, 770), 47 | PhoneKey.Five => (1336, 770), 48 | PhoneKey.Six => (1477, 770), 49 | PhoneKey.Seven => (1209, 852), 50 | PhoneKey.Eight => (1336, 852), 51 | PhoneKey.Nine => (1477, 852), 52 | PhoneKey.Star => (1209, 941), 53 | PhoneKey.Hash => (1477, 941), 54 | PhoneKey.A => (1633, 697), 55 | PhoneKey.B => (1633, 770), 56 | PhoneKey.C => (1633, 852), 57 | PhoneKey.D => (1633, 941), 58 | PhoneKey.None => (-1, -1), 59 | _ => throw new ArgumentOutOfRangeException(nameof(key), key, $"Unhandled {nameof(PhoneKey)}") 60 | }; 61 | 62 | /// Converts a `PhoneKey` to its UTF-8 symbol. 63 | /// The key to convert. 64 | /// A `char` representing the `PhoneKey`. 65 | public static char ToSymbol(this PhoneKey key) => key switch 66 | { 67 | PhoneKey.Zero => '0', 68 | PhoneKey.One => '1', 69 | PhoneKey.Two => '2', 70 | PhoneKey.Three => '3', 71 | PhoneKey.Four => '4', 72 | PhoneKey.Five => '5', 73 | PhoneKey.Six => '6', 74 | PhoneKey.Seven => '7', 75 | PhoneKey.Eight => '8', 76 | PhoneKey.Nine => '9', 77 | PhoneKey.Star => '*', 78 | PhoneKey.Hash => '#', 79 | PhoneKey.A => 'A', 80 | PhoneKey.B => 'B', 81 | PhoneKey.C => 'C', 82 | PhoneKey.D => 'D', 83 | PhoneKey.None => ' ', 84 | _ => throw new ArgumentOutOfRangeException(nameof(key), key, $"Unhandled {nameof(PhoneKey)}") 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/benchmark/Program.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark { 2 | using BenchmarkDotNet.Running; 3 | 4 | // `dtmf-detection\test\benchmark> dotnet run -c Release` 5 | // `dtmf-detection\test\benchmark> dotnet run -c Release -- --filter *LastRelease*` 6 | // `dtmf-detection\test\benchmark> dotnet run -c Release -- --filter *Current` 7 | // `dtmf-detection\test\benchmark> dotnet run -c Release -- --list flat` 8 | public static class Program { 9 | public static void Main(string[] args) => BenchmarkSwitcher 10 | .FromAssembly(typeof(Program).Assembly) 11 | .Run(args); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/benchmark/benchmark.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | 8.0 7 | enable 8 | true 9 | Benchmark 10 | benchmark 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | PreserveNewest 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/Benchmarks.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.CurrentVsLastRelease { 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using BenchmarkDotNet.Attributes; 6 | using BenchmarkDotNet.Diagnostics.Windows.Configs; 7 | using DtmfDetection; 8 | using DtmfDetection.NAudio; 9 | using DtmfDetection.NAudio.LastRelease; 10 | using NAudio.Wave; 11 | 12 | [MemoryDiagnoser, NativeMemoryProfiler] 13 | [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable")] 14 | public class Benchmarks { 15 | private readonly AudioFileReader audioFile = new AudioFileReader("./current-vs-last-release/test.mp3"); 16 | 17 | [Benchmark(Baseline = true)] 18 | public List LastRelease() => audioFile.DtmfTones().ToList(); 19 | 20 | [Benchmark] 21 | public List CurrentRelease() => audioFile.DtmfChanges(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/AmplitudeEstimator.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System; 3 | 4 | public class AmplitudeEstimator 5 | { 6 | private readonly double c; 7 | 8 | private double s1; 9 | 10 | private double s2; 11 | 12 | public AmplitudeEstimator(double targetFrequency, double sampleRate, int numberOfSamples) 13 | { 14 | var k = Math.Round(targetFrequency / sampleRate * numberOfSamples); 15 | c = 2.0 * Math.Cos(2.0 * Math.PI * k / numberOfSamples); 16 | } 17 | 18 | public double AmplitudeSquared => s1*s1 + s2*s2 - s1*s2*c; 19 | 20 | public void Add(float sample) 21 | { 22 | var s0 = sample + c * s1 - s2; 23 | s2 = s1; 24 | s1 = s0; 25 | } 26 | 27 | public void Reset() => s1 = s2 = .0; 28 | } 29 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/AmplitudeEstimatorFactory.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | public class AmplitudeEstimatorFactory 3 | { 4 | private readonly int sampleRate; 5 | 6 | private readonly int sampleBlockSize; 7 | 8 | public AmplitudeEstimatorFactory(int sampleRate, int sampleBlockSize) 9 | { 10 | this.sampleRate = sampleRate; 11 | this.sampleBlockSize = sampleBlockSize; 12 | } 13 | 14 | public AmplitudeEstimator CreateFor(int tone) => new AmplitudeEstimator(tone, sampleRate, sampleBlockSize); 15 | } 16 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DetectorConfig.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | public class DetectorConfig 3 | { 4 | public int MaxSampleRate { get; } = 8000; 5 | 6 | // Using 205 samples minimizes error (distance of DTMF frequency to center of DFT bin). 7 | public int SampleBlockSize { get; } = 205; 8 | 9 | public double PowerThreshold { get; } = 100.0; 10 | } 11 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DtmfAudio.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | 6 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 7 | public class DtmfAudio 8 | { 9 | private readonly DtmfChangeHandler[] dtmfChangeHandlers; 10 | 11 | private readonly ISampleSource source; 12 | 13 | private readonly DtmfDetector dtmfDetector; 14 | 15 | private readonly int numChannels; 16 | 17 | public DtmfAudio(DtmfDetector dtmfDetector, ISampleSource source, DtmfChangeHandler[] dtmfChangeHandlers) 18 | { 19 | this.dtmfDetector = dtmfDetector; 20 | this.source = source; 21 | this.dtmfChangeHandlers = dtmfChangeHandlers; 22 | numChannels = source.Channels; 23 | } 24 | 25 | public static DtmfAudio CreateFrom(ISampleSource source, DetectorConfig config) 26 | { 27 | var pureTones = Enumerable 28 | .Range(0, source.Channels) 29 | .Select(_ => new PureTones(new AmplitudeEstimatorFactory(source.SampleRate, config.SampleBlockSize))) 30 | .ToArray(); 31 | 32 | var dtmfChangeHandlers = Enumerable 33 | .Range(0, source.Channels) 34 | .Select(_ => new DtmfChangeHandler()) 35 | .ToArray(); 36 | 37 | return new DtmfAudio(new DtmfDetector(config, pureTones), source, dtmfChangeHandlers); 38 | } 39 | 40 | public bool Forward(Func dtmfStarting, Action dtmfStopping) 41 | { 42 | // Save value of HasSamples, because it might be different after analyzing (i.e. reading). 43 | var canAnalyze = source.HasSamples; 44 | 45 | var dtmfTones = canAnalyze 46 | ? dtmfDetector.Analyze(source.Samples) 47 | // Reached end of data: generate DtmfTone.None's and flush the state machines to handle cut-off tones. 48 | : Enumerable.Repeat(DtmfTone.None, numChannels).ToArray(); 49 | 50 | for (var channel = 0; channel < numChannels; channel++) 51 | { 52 | dtmfChangeHandlers[channel].Handle( 53 | dtmfTones[channel], 54 | dtmfStarting.Apply(channel), 55 | dtmfStopping.Apply(channel)); 56 | } 57 | 58 | return canAnalyze; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DtmfChangeHandler.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | 3 | namespace DtmfDetection.LastRelease { 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | 7 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 8 | public class DtmfChangeHandler 9 | { 10 | private enum State 11 | { 12 | NoDtmf, 13 | Dtmf 14 | } 15 | 16 | private State currentState = State.NoDtmf; 17 | 18 | private DtmfTone lastTone = DtmfTone.None; 19 | 20 | private object clientData; 21 | 22 | [SuppressMessage("Usage", "CA2208:Instantiate argument exceptions correctly")] 23 | public void Handle(DtmfTone tone, Func handleStart, Action handleEnd) 24 | { 25 | switch (currentState) 26 | { 27 | case State.NoDtmf: 28 | if (tone != DtmfTone.None) 29 | { 30 | clientData = handleStart(tone); 31 | currentState = State.Dtmf; 32 | } 33 | break; 34 | case State.Dtmf: 35 | if (tone == DtmfTone.None) 36 | { 37 | handleEnd((T)clientData, lastTone); 38 | currentState = State.NoDtmf; 39 | } 40 | else if (tone != lastTone) 41 | { 42 | handleEnd((T)clientData, lastTone); 43 | clientData = handleStart(tone); 44 | } 45 | break; 46 | default: 47 | throw new ArgumentOutOfRangeException(); 48 | } 49 | 50 | lastTone = tone; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DtmfClassification.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | public static class DtmfClassification 6 | { 7 | private static readonly Dictionary, DtmfTone> DtmfTones = new Dictionary, DtmfTone> 8 | { 9 | [KeyOf(DtmfTone.One)] = DtmfTone.One, [KeyOf(DtmfTone.Two)] = DtmfTone.Two, [KeyOf(DtmfTone.Three)] = DtmfTone.Three, [KeyOf(DtmfTone.A)] = DtmfTone.A, 10 | [KeyOf(DtmfTone.Four)] = DtmfTone.Four, [KeyOf(DtmfTone.Five)] = DtmfTone.Five, [KeyOf(DtmfTone.Six)] = DtmfTone.Six, [KeyOf(DtmfTone.B)] = DtmfTone.B, 11 | [KeyOf(DtmfTone.Seven)] = DtmfTone.Seven, [KeyOf(DtmfTone.Eight)] = DtmfTone.Eight, [KeyOf(DtmfTone.Nine)] = DtmfTone.Nine, [KeyOf(DtmfTone.C)] = DtmfTone.C, 12 | [KeyOf(DtmfTone.Star)] = DtmfTone.Star, [KeyOf(DtmfTone.Zero)] = DtmfTone.Zero, [KeyOf(DtmfTone.Hash)] = DtmfTone.Hash, [KeyOf(DtmfTone.D)] = DtmfTone.D 13 | }; 14 | 15 | public static DtmfTone For(int highTone, int lowTone) => DtmfTones.TryGetValue(KeyOf(highTone, lowTone), out var tone) ? tone : DtmfTone.None; 16 | 17 | private static Tuple KeyOf(DtmfTone t) => KeyOf(t.HighTone, t.LowTone); 18 | 19 | private static Tuple KeyOf(int highTone, int lowTone) => Tuple.Create(highTone, lowTone); 20 | } 21 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DtmfDetector.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | 6 | public class DtmfDetector 7 | { 8 | private readonly DetectorConfig config; 9 | 10 | private readonly PureTones[] powers; 11 | 12 | private readonly int numChannels; 13 | 14 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 15 | public DtmfDetector(DetectorConfig config, PureTones[] powers) 16 | { 17 | this.config = config; 18 | this.powers = powers; 19 | numChannels = powers.Length; 20 | } 21 | 22 | public DtmfTone[] Analyze(IEnumerable samples) 23 | { 24 | foreach (var p in powers) 25 | p.ResetAmplitudes(); 26 | 27 | var channel = 0; 28 | foreach (var sample in samples.Take(config.SampleBlockSize * numChannels)) 29 | { 30 | powers[channel].AddSample(sample); 31 | channel = (channel + 1) % numChannels; 32 | } 33 | 34 | return powers 35 | .Select(p => GetDtmfToneFromPowers(p, config.PowerThreshold)) 36 | .ToArray(); 37 | } 38 | 39 | private static DtmfTone GetDtmfToneFromPowers(PureTones powers, double threshold) 40 | { 41 | var highTone = powers.FindStrongestHighTone(); 42 | var lowTone = powers.FindStrongestLowTone(); 43 | 44 | return powers[highTone] < threshold || powers[lowTone] < threshold 45 | ? DtmfTone.None 46 | : DtmfClassification.For(highTone, lowTone); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/DtmfTone.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | 3 | namespace DtmfDetection.LastRelease { 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | 7 | [SuppressMessage("Performance", "EPS01:A struct can be made readonly")] 8 | public struct DtmfTone : IEquatable 9 | { 10 | private DtmfTone(int highTone, int lowTone, PhoneKey key) 11 | { 12 | HighTone = highTone; 13 | LowTone = lowTone; 14 | Key = key; 15 | } 16 | 17 | #region DTMF Tone definitions 18 | 19 | public static DtmfTone None { get; } = new DtmfTone(0, 0, PhoneKey.None); 20 | 21 | public static DtmfTone Zero { get; } = new DtmfTone(1336, 941, PhoneKey.Zero); 22 | 23 | public static DtmfTone One { get; } = new DtmfTone(1209, 697, PhoneKey.One); 24 | 25 | public static DtmfTone Two { get; } = new DtmfTone(1336, 697, PhoneKey.Two); 26 | 27 | public static DtmfTone Three { get; } = new DtmfTone(1477, 697, PhoneKey.Three); 28 | 29 | public static DtmfTone Four { get; } = new DtmfTone(1209, 770, PhoneKey.Four); 30 | 31 | public static DtmfTone Five { get; } = new DtmfTone(1336, 770, PhoneKey.Five); 32 | 33 | public static DtmfTone Six { get; } = new DtmfTone(1477, 770, PhoneKey.Six); 34 | 35 | public static DtmfTone Seven { get; } = new DtmfTone(1209, 852, PhoneKey.Seven); 36 | 37 | public static DtmfTone Eight { get; } = new DtmfTone(1336, 852, PhoneKey.Eight); 38 | 39 | public static DtmfTone Nine { get; } = new DtmfTone(1477, 852, PhoneKey.Nine); 40 | 41 | public static DtmfTone Star { get; } = new DtmfTone(1209, 941, PhoneKey.Star); 42 | 43 | public static DtmfTone Hash { get; } = new DtmfTone(1477, 941, PhoneKey.Hash); 44 | 45 | public static DtmfTone A { get; } = new DtmfTone(1633, 697, PhoneKey.A); 46 | 47 | public static DtmfTone B { get; } = new DtmfTone(1633, 770, PhoneKey.B); 48 | 49 | public static DtmfTone C { get; } = new DtmfTone(1633, 852, PhoneKey.C); 50 | 51 | public static DtmfTone D { get; } = new DtmfTone(1633, 941, PhoneKey.D); 52 | 53 | #endregion DTMF Tone definitions 54 | 55 | public PhoneKey Key { get; } 56 | 57 | public int HighTone { get; } 58 | 59 | public int LowTone { get; } 60 | 61 | public override string ToString() => Key.ToString(); 62 | 63 | #region Equality implementations 64 | 65 | public override bool Equals(object obj) => obj is DtmfTone dtmfTone && Equals(dtmfTone); 66 | 67 | public bool Equals(DtmfTone other) => Key == other.Key; 68 | 69 | public override int GetHashCode() => Key.GetHashCode(); 70 | 71 | public static bool operator ==(DtmfTone a, DtmfTone b) => a.Equals(b); 72 | 73 | public static bool operator !=(DtmfTone a, DtmfTone b) => !(a == b); 74 | 75 | #endregion 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/ISampleSource.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System.Collections.Generic; 3 | 4 | public interface ISampleSource 5 | { 6 | bool HasSamples { get; } 7 | 8 | int SampleRate { get; } 9 | 10 | int Channels { get; } 11 | 12 | IEnumerable Samples { get; } 13 | } 14 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/DtmfOccurence.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | 3 | namespace DtmfDetection.NAudio.LastRelease { 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | using DtmfDetection.LastRelease; 7 | 8 | /// Represents a DTMF tone in an audio file or stream. 9 | [SuppressMessage("Style", "IDE0041:Use 'is null' check")] 10 | public class DtmfOccurence : IEquatable 11 | { 12 | /// Creates a new instance of DtmfOccurence. 13 | /// The DTMF tone. 14 | /// The audio channel where the DTMF tone occured. 15 | /// The estimated position in the file or stream where the DTMF tone occured. 16 | /// The estimated duration of the DTMF tone. 17 | public DtmfOccurence(DtmfTone dtmfTone, int channel, TimeSpan position, TimeSpan duration) 18 | { 19 | DtmfTone = dtmfTone; 20 | Channel = channel; 21 | Position = position; 22 | Duration = duration; 23 | } 24 | 25 | /// The detected DTMF tone. 26 | public DtmfTone DtmfTone { get; } 27 | 28 | /// The audio channel where the DTMF tone occured. 0 for left, 1 for right etc. 29 | public int Channel { get; } 30 | 31 | /// The estimated position in the file or stream where the DTMF tone occured. 32 | /// The accuracy of the estimation depends on the sample rate f_s of the audio data and is 33 | /// calculated by 205 / f_s Hz * 1000 ms (e.g. for a sample rate f_s of 8000 Hz it is 25.625 ms). 34 | public TimeSpan Position { get; } 35 | 36 | /// The estimated duration of the DTMF tone. 37 | public TimeSpan Duration { get; } 38 | 39 | /// 40 | public override string ToString() => $"{DtmfTone} ({Channel}) @ {Position} for {(int)Duration.TotalMilliseconds} ms"; 41 | 42 | #region Equality implementations 43 | 44 | /// 45 | public override bool Equals(object obj) => !ReferenceEquals(obj, null) && Equals(obj as DtmfOccurence); 46 | 47 | /// 48 | public bool Equals(DtmfOccurence other) 49 | => !ReferenceEquals(other, null) 50 | && DtmfTone == other.DtmfTone 51 | && Channel == other.Channel 52 | && Position == other.Position 53 | && Duration == other.Duration; 54 | 55 | /// 56 | public override int GetHashCode() => new { DtmfTone, Channel, Position, Duration }.GetHashCode(); 57 | 58 | /// Compares two DtmfOccurence's for equality. 59 | /// The left-hand side DtmfOccurence. 60 | /// The right-hand side DtmfOccurence. 61 | /// True if the DtmfOccurence's a and b are equal, false otherwise. 62 | public static bool operator ==(DtmfOccurence a, DtmfOccurence b) => ReferenceEquals(a, null) ? ReferenceEquals(b, null) : a.Equals(b); 63 | 64 | /// Compares two DtmfOccurence's for inequality. 65 | /// The left-hand side DtmfOccurence. 66 | /// The right-hand side DtmfOccurence. 67 | /// True if the DtmfOccurence's a and b are not equal, false otherwise. 68 | public static bool operator !=(DtmfOccurence a, DtmfOccurence b) => !(a == b); 69 | 70 | #endregion 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/MonoSampleProvider.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | 3 | namespace DtmfDetection.NAudio.LastRelease { 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | using global::NAudio.Utils; 7 | using global::NAudio.Wave; 8 | 9 | public class MonoSampleProvider : ISampleProvider 10 | { 11 | private readonly ISampleProvider sourceProvider; 12 | 13 | private readonly int sourceChannels; 14 | 15 | private float[] sourceBuffer; 16 | 17 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 18 | public MonoSampleProvider(ISampleProvider sourceProvider) { 19 | this.sourceProvider = sourceProvider; 20 | sourceChannels = sourceProvider.WaveFormat.Channels; 21 | WaveFormat = new WaveFormat(sourceProvider.WaveFormat.SampleRate, sourceProvider.WaveFormat.BitsPerSample, 1); 22 | } 23 | 24 | public WaveFormat WaveFormat { get; } 25 | 26 | public int Read(float[] buffer, int offset, int count) 27 | { 28 | var sourceBytesRequired = count * sourceChannels; 29 | sourceBuffer = BufferHelpers.Ensure(sourceBuffer, sourceBytesRequired); 30 | 31 | var samplesRead = sourceProvider.Read(sourceBuffer, 0, sourceBytesRequired); 32 | var sampleFramesRead = samplesRead / sourceChannels; 33 | 34 | for (var sampleIndex = 0; sampleIndex < samplesRead; sampleIndex += sourceChannels) 35 | buffer[offset++] = Clamp(AverageOfSampleFrame(sampleIndex)); 36 | 37 | return sampleFramesRead; 38 | } 39 | 40 | private float AverageOfSampleFrame(int frameStart) 41 | { 42 | var mixedValue = 0.0f; 43 | 44 | for (var channel = 0; channel < sourceChannels; channel++) 45 | mixedValue += sourceBuffer[frameStart + channel]; 46 | 47 | return mixedValue / sourceChannels; 48 | } 49 | 50 | private static float Clamp(float value) => Math.Max(-1.0f, Math.Min(value, 1.0f)); 51 | } 52 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/SampleBlockProvider.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio.LastRelease { 2 | using System.Diagnostics.CodeAnalysis; 3 | using global::NAudio.Wave; 4 | 5 | public class SampleBlockProvider 6 | { 7 | private readonly ISampleProvider source; 8 | 9 | public SampleBlockProvider(ISampleProvider source, int blockSize) 10 | { 11 | this.source = source; 12 | BlockSize = blockSize * Channels; 13 | CurrentBlock = new float[BlockSize]; 14 | } 15 | 16 | public int SampleRate => source.WaveFormat.SampleRate; 17 | 18 | public int BlockSize { get; } 19 | 20 | public int Channels => source.WaveFormat.Channels; 21 | 22 | [SuppressMessage("Performance", "CA1819:Properties should not return arrays")] 23 | public float[] CurrentBlock { get; } 24 | 25 | public int ReadNextBlock() => source.Read(CurrentBlock, 0, BlockSize); 26 | } 27 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/SampleProviderExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio.LastRelease { 2 | using System.Diagnostics.CodeAnalysis; 3 | using global::NAudio.Wave; 4 | using global::NAudio.Wave.SampleProviders; 5 | 6 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 7 | public static class SampleProviderExtensions 8 | { 9 | public static ISampleProvider DownsampleTo(this ISampleProvider source, int maxSampleRate) 10 | => source.WaveFormat.SampleRate > maxSampleRate 11 | ? new WdlResamplingSampleProvider(source, maxSampleRate) 12 | : source; 13 | 14 | public static ISampleProvider AsMono(this ISampleProvider source) 15 | => source.WaveFormat.Channels > 1 16 | ? new MonoSampleProvider(source) 17 | : source; 18 | 19 | public static SampleBlockProvider Blockwise(this ISampleProvider source, int blockSize) 20 | => new SampleBlockProvider(source, blockSize); 21 | } 22 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/StaticSampleSource.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio.LastRelease { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Linq; 6 | using DtmfDetection.LastRelease; 7 | using global::NAudio.Wave; 8 | 9 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 10 | public class StaticSampleSource : ISampleSource 11 | { 12 | private readonly SampleBlockProvider samples; 13 | 14 | private int numSamplesRead; 15 | 16 | public StaticSampleSource(DetectorConfig config, IWaveProvider source, bool forceMono = true) 17 | { 18 | var sampleProvider = source.ToSampleProvider(); 19 | 20 | if (forceMono) 21 | sampleProvider = sampleProvider.AsMono(); 22 | 23 | samples = sampleProvider.DownsampleTo(config.MaxSampleRate).Blockwise(config.SampleBlockSize); 24 | 25 | // Optimistically assume that we are going to read at least BlockSize bytes. 26 | numSamplesRead = samples.BlockSize; 27 | } 28 | 29 | public bool HasSamples => numSamplesRead >= samples.BlockSize; 30 | 31 | public int SampleRate => samples.SampleRate; 32 | 33 | public int Channels => samples.Channels; 34 | 35 | public IEnumerable Samples 36 | { 37 | get 38 | { 39 | if (!HasSamples) 40 | throw new InvalidOperationException("No more data available"); 41 | 42 | numSamplesRead = samples.ReadNextBlock(); 43 | return samples.CurrentBlock.Take(numSamplesRead); 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/NAudio/WaveStreamExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.NAudio.LastRelease { 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using DtmfDetection.LastRelease; 6 | using global::NAudio.Wave; 7 | 8 | /// Provides an extension method to NAudio's WaveStream class that detects DTMF tones in 9 | /// audio files or streams. 10 | public static class WaveStreamExtensions 11 | { 12 | /// Reads a WaveStream and enumerates all present DTMF tones. 13 | /// By default this method forces a mono conversion by averaging all audio channels first. Turn it off with the 14 | /// forceMono flag in order to analyze each channel separately. 15 | /// The audio data to analyze. 16 | /// Indicates whether the audio data should be converted to mono first. Default is true. 17 | /// All detected DTMF tones along with their positions (i.e. audio channel, start time, and duration). 18 | [SuppressMessage("Performance", "RCS1080:Use 'Count/Length' property instead of 'Any' method.")] 19 | public static IEnumerable DtmfTones(this WaveStream waveFile, bool forceMono = true) 20 | { 21 | var config = new DetectorConfig(); 22 | var dtmfAudio = DtmfAudio.CreateFrom(new StaticSampleSource(config, waveFile, forceMono), config); 23 | var detectedTones = new Queue(); 24 | 25 | while (detectedTones.Any() || detectedTones.AddNextFrom(dtmfAudio, waveFile)) 26 | { 27 | if (detectedTones.Any()) 28 | yield return detectedTones.Dequeue(); 29 | } 30 | 31 | // Yield any tones that might have been cut off by EOF. 32 | foreach (var tone in detectedTones) 33 | yield return tone; 34 | } 35 | } 36 | 37 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 38 | public static class DtmfQueueExtensions 39 | { 40 | public static bool AddNextFrom(this Queue detectedTones, DtmfAudio dtmfAudio, WaveStream waveFile) => dtmfAudio.Forward( 41 | (_, __) => waveFile.CurrentTime, 42 | (channel, start, tone) => detectedTones.Enqueue(new DtmfOccurence(tone, channel, start, waveFile.CurrentTime - start))); 43 | } 44 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/PartialApplication.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease 2 | { 3 | using System; 4 | 5 | public static class PartialApplication 6 | { 7 | // Partial application of binary Func's. 8 | public static Func Apply(this Func func, T1 arg1) => 9 | arg2 => func(arg1, arg2); 10 | 11 | // Partial application of ternary Action's. 12 | public static Action Apply(this Action action, T1 arg1) => 13 | (arg2, arg3) => action(arg1, arg2, arg3); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/PhoneKey.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | public enum PhoneKey 3 | { 4 | None, 5 | Zero, 6 | One, 7 | Two, 8 | Three, 9 | Four, 10 | Five, 11 | Six, 12 | Seven, 13 | Eight, 14 | Nine, 15 | Star, 16 | Hash, 17 | A, 18 | B, 19 | C, 20 | D 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/last-release/PureTones.cs: -------------------------------------------------------------------------------- 1 | namespace DtmfDetection.LastRelease { 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | 6 | public class PureTones 7 | { 8 | private static readonly IEnumerable LowPureTones = new[] { 697, 770, 852, 941 }; 9 | 10 | private static readonly IEnumerable HighPureTones = new[] { 1209, 1336, 1477, 1633 }; 11 | 12 | private readonly Dictionary estimators; 13 | 14 | [SuppressMessage("Design", "CA1062:Validate arguments of public methods")] 15 | public PureTones(AmplitudeEstimatorFactory estimatorFactory) => estimators = LowPureTones 16 | .Concat(HighPureTones) 17 | .ToDictionary(tone => tone, estimatorFactory.CreateFor); 18 | 19 | public double this[int tone] => estimators[tone].AmplitudeSquared; 20 | 21 | public void ResetAmplitudes() 22 | { 23 | foreach (var estimator in estimators.Values) 24 | estimator.Reset(); 25 | } 26 | 27 | public void AddSample(float sample) 28 | { 29 | foreach (var estimator in estimators.Values) 30 | estimator.Add(sample); 31 | } 32 | 33 | public int FindStrongestHighTone() => StrongestOf(HighPureTones); 34 | 35 | public int FindStrongestLowTone() => StrongestOf(LowPureTones); 36 | 37 | private int StrongestOf(IEnumerable pureTones) => pureTones 38 | .Select( 39 | tone => new { 40 | Tone = tone, 41 | Power = estimators[tone].AmplitudeSquared 42 | }) 43 | .OrderBy(result => result.Power) 44 | .Select(result => result.Tone) 45 | .Last(); 46 | } 47 | } -------------------------------------------------------------------------------- /test/benchmark/current-vs-last-release/test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bert2/DtmfDetection/cbe63a91600493d0e950f0fddc4c7f645bea925d/test/benchmark/current-vs-last-release/test.mp3 -------------------------------------------------------------------------------- /test/benchmark/stateless-detector/Benchmarks.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.StatelessDetector { 2 | using System.Linq; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Diagnostics.Windows.Configs; 5 | using DtmfDetection; 6 | 7 | using static DtmfDetection.DtmfGenerator; 8 | 9 | [MemoryDiagnoser, NativeMemoryProfiler] 10 | public class Benchmarks { 11 | private readonly Detector currentDetector = new Detector(channels: 1, Config.Default); 12 | private readonly StatefulDetector statefulDetector = new StatefulDetector(); 13 | private readonly LessStatefulDetector lessStatefulDetector = new LessStatefulDetector(); 14 | private readonly MuchLessStatefulDetector muchLessStatefulDetector = new MuchLessStatefulDetector(); 15 | private readonly LinqedDetector linqedDetector = new LinqedDetector(); 16 | 17 | private readonly float[] sampleBlock = Generate(PhoneKey.One).Take(Config.DefaultSampleBlockSize).ToArray(); 18 | 19 | [Benchmark(Baseline = true)] 20 | public object CurrentDetector() => currentDetector.Detect(sampleBlock); 21 | 22 | public PhoneKey StatefulDetector() => statefulDetector.Analyze(sampleBlock); 23 | 24 | [Benchmark] 25 | public PhoneKey LessStatefulDetector() => lessStatefulDetector.Analyze(sampleBlock); 26 | 27 | [Benchmark] 28 | public PhoneKey MuchLessStatefulDetector() => muchLessStatefulDetector.Analyze(sampleBlock); 29 | 30 | [Benchmark] 31 | public PhoneKey LinqedDetector() => linqedDetector.Analyze(sampleBlock); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/benchmark/stateless-detector/LessStatefulDetector.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.StatelessDetector { 2 | using System; 3 | using System.Linq; 4 | using DtmfDetection; 5 | 6 | public class LessStatefulDetector { 7 | private static readonly int[] lowTones = new[] { 697, 770, 852, 941 }; 8 | private static readonly int[] highTones = new[] { 1209, 1336, 1477, 1633 }; 9 | private readonly int sampleRate; 10 | private readonly int numSamples; 11 | private readonly double threshold; 12 | private readonly Goertzel[] initLoResps; 13 | private readonly Goertzel[] initHiResps; 14 | 15 | public LessStatefulDetector() { 16 | sampleRate = 8000; 17 | numSamples = 205; 18 | threshold = 35.0; 19 | initLoResps = lowTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 20 | initHiResps = highTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 21 | } 22 | 23 | public PhoneKey Analyze(in ReadOnlySpan sampleBlock) { 24 | var loResps = new[] { initLoResps[0], initLoResps[1], initLoResps[2], initLoResps[3] }; 25 | var hiResps = new[] { initHiResps[0], initHiResps[1], initHiResps[2], initHiResps[3] }; 26 | var length = Math.Min(numSamples, sampleBlock.Length); 27 | 28 | for (var i = 0; i < length; i++) { 29 | loResps[0] = loResps[0].AddSample(sampleBlock[i]); 30 | loResps[1] = loResps[1].AddSample(sampleBlock[i]); 31 | loResps[2] = loResps[2].AddSample(sampleBlock[i]); 32 | loResps[3] = loResps[3].AddSample(sampleBlock[i]); 33 | 34 | hiResps[0] = hiResps[0].AddSample(sampleBlock[i]); 35 | hiResps[1] = hiResps[1].AddSample(sampleBlock[i]); 36 | hiResps[2] = hiResps[2].AddSample(sampleBlock[i]); 37 | hiResps[3] = hiResps[3].AddSample(sampleBlock[i]); 38 | } 39 | 40 | var (fstLowIdx, sndLowIdx) = FindMaxTwo(loResps); 41 | var (fstLow, sndLow) = (loResps[fstLowIdx].NormResponse, loResps[sndLowIdx].NormResponse); 42 | 43 | var (fstHighIdx, sndHighIdx) = FindMaxTwo(hiResps); 44 | var (fstHigh, sndHigh) = (hiResps[fstHighIdx].NormResponse, hiResps[sndHighIdx].NormResponse); 45 | 46 | return fstLow < threshold || fstHigh < threshold 47 | || fstLow > threshold && sndLow > threshold 48 | || fstHigh > threshold && sndHigh > threshold 49 | ? PhoneKey.None 50 | : (highTones[fstHighIdx], lowTones[fstLowIdx]).ToPhoneKey(); 51 | } 52 | 53 | private static (int fstIdx, int sndIdx) FindMaxTwo(in ReadOnlySpan goertzels) { 54 | int fst = 0, snd = 1; 55 | 56 | for (var i = 1; i < 4; i++) { 57 | if (goertzels[i].NormResponse > goertzels[fst].NormResponse) { 58 | snd = fst; 59 | fst = i; 60 | } else if (goertzels[i].NormResponse > goertzels[snd].NormResponse) { 61 | snd = i; 62 | } 63 | } 64 | 65 | return (fst, snd); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/benchmark/stateless-detector/LinqedDetector.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.StatelessDetector { 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using DtmfDetection; 5 | 6 | public class LinqedDetector { 7 | private static readonly int[] lowTones = new[] { 697, 770, 852, 941 }; 8 | private static readonly int[] highTones = new[] { 1209, 1336, 1477, 1633 }; 9 | private readonly int sampleRate; 10 | private readonly int numSamples; 11 | private readonly double threshold; 12 | private readonly IEnumerable lows; 13 | private readonly IEnumerable highs; 14 | 15 | public LinqedDetector() { 16 | sampleRate = 8000; 17 | numSamples = 205; 18 | threshold = 35.0; 19 | lows = lowTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 20 | highs = highTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 21 | } 22 | 23 | public PhoneKey Analyze(IEnumerable sampleBlock) { 24 | var (loResps, hiResps) = sampleBlock 25 | .Take(numSamples) 26 | .Aggregate( 27 | (lows, highs), 28 | (goertzel, sample) => (goertzel.lows.Select(g => g.AddSample(sample)), goertzel.highs.Select(g => g.AddSample(sample)))); 29 | 30 | var (fstLowIdx, fstLow, sndLowIdx, sndLow) = FindMaxTwo(loResps); 31 | var (fstHighIdx, fstHigh, sndHighIdx, sndHigh) = FindMaxTwo(hiResps); 32 | 33 | return fstLow < threshold || fstHigh < threshold 34 | || fstLow > threshold && sndLow > threshold 35 | || fstHigh > threshold && sndHigh > threshold 36 | ? PhoneKey.None 37 | : (highTones[fstHighIdx], lowTones[fstLowIdx]).ToPhoneKey(); 38 | } 39 | 40 | private static (int fstIdx, double fstVal, int sndIdx, double sndVal) FindMaxTwo(IEnumerable goertzels) { 41 | int fstIdx = 0, sndIdx = 1; 42 | double fstVal = goertzels.First().NormResponse, sndVal = goertzels.Skip(1).First().NormResponse; 43 | 44 | foreach (var (g, i) in goertzels.Skip(1).Select((g, i) => (g, i))) { 45 | if (g.NormResponse > fstVal) { 46 | sndIdx = fstIdx; 47 | sndVal = fstVal; 48 | fstIdx = i; 49 | fstVal = g.NormResponse; 50 | } else if (g.NormResponse > sndVal) { 51 | sndIdx = i; 52 | sndVal = g.NormResponse; 53 | } 54 | } 55 | 56 | return (fstIdx, fstVal, sndIdx, sndVal); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/benchmark/stateless-detector/MuchLessStatefulDetector.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.StatelessDetector { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection; 6 | 7 | public class MuchLessStatefulDetector { 8 | private static readonly int[] lowTones = new[] { 697, 770, 852, 941 }; 9 | private static readonly int[] highTones = new[] { 1209, 1336, 1477, 1633 }; 10 | private readonly int sampleRate; 11 | private readonly int numSamples; 12 | private readonly double threshold; 13 | private readonly Goertzel[] lows; 14 | private readonly Goertzel[] highs; 15 | 16 | public MuchLessStatefulDetector() { 17 | sampleRate = 8000; 18 | numSamples = 205; 19 | threshold = 35.0; 20 | lows = lowTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 21 | highs = highTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 22 | } 23 | 24 | public PhoneKey Analyze(IEnumerable sampleBlock) { 25 | var (loResps, hiResps) = sampleBlock.Take(numSamples).Aggregate((lows, highs), (goertzel, sample) => 26 | (new[] { 27 | goertzel.lows[0].AddSample(sample), 28 | goertzel.lows[1].AddSample(sample), 29 | goertzel.lows[2].AddSample(sample), 30 | goertzel.lows[3].AddSample(sample) 31 | }, 32 | new[] { 33 | goertzel.highs[0].AddSample(sample), 34 | goertzel.highs[1].AddSample(sample), 35 | goertzel.highs[2].AddSample(sample), 36 | goertzel.highs[3].AddSample(sample) 37 | })); 38 | 39 | var (fstLowIdx, sndLowIdx) = FindMaxTwo(loResps); 40 | var (fstLow, sndLow) = (loResps[fstLowIdx].NormResponse, loResps[sndLowIdx].NormResponse); 41 | 42 | var (fstHighIdx, sndHighIdx) = FindMaxTwo(hiResps); 43 | var (fstHigh, sndHigh) = (hiResps[fstHighIdx].NormResponse, hiResps[sndHighIdx].NormResponse); 44 | 45 | return fstLow < threshold || fstHigh < threshold 46 | || fstLow > threshold && sndLow > threshold 47 | || fstHigh > threshold && sndHigh > threshold 48 | ? PhoneKey.None 49 | : (highTones[fstHighIdx], lowTones[fstLowIdx]).ToPhoneKey(); 50 | } 51 | 52 | private static (int fstIdx, int sndIdx) FindMaxTwo(in ReadOnlySpan goertzels) { 53 | int fst = 0, snd = 1; 54 | 55 | for (var i = 1; i < 4; i++) { 56 | if (goertzels[i].NormResponse > goertzels[fst].NormResponse) { 57 | snd = fst; 58 | fst = i; 59 | } else if (goertzels[i].NormResponse > goertzels[snd].NormResponse) { 60 | snd = i; 61 | } 62 | } 63 | 64 | return (fst, snd); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/benchmark/stateless-detector/StatefulDetector.cs: -------------------------------------------------------------------------------- 1 | namespace Benchmark.StatelessDetector { 2 | using System; 3 | using System.Linq; 4 | using DtmfDetection; 5 | 6 | public class StatefulDetector { 7 | private static readonly int[] lowTones = new[] { 697, 770, 852, 941 }; 8 | private static readonly int[] highTones = new[] { 1209, 1336, 1477, 1633 }; 9 | private readonly int sampleRate; 10 | private readonly int numSamples; 11 | private readonly double threshold; 12 | private readonly Goertzel[] lows; 13 | private readonly Goertzel[] highs; 14 | 15 | public StatefulDetector() { 16 | sampleRate = 8000; 17 | numSamples = 205; 18 | threshold = 35.0; 19 | lows = lowTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 20 | highs = highTones.Select(f => Goertzel.Init(f, sampleRate, numSamples)).ToArray(); 21 | } 22 | 23 | public PhoneKey Analyze(in ReadOnlySpan sampleBlock) { 24 | ResetGoertzelResponses(); 25 | var length = Math.Min(numSamples, sampleBlock.Length); 26 | 27 | for (var i = 0; i < length; i++) { 28 | lows[0] = lows[0].AddSample(sampleBlock[i]); 29 | lows[1] = lows[1].AddSample(sampleBlock[i]); 30 | lows[2] = lows[2].AddSample(sampleBlock[i]); 31 | lows[3] = lows[3].AddSample(sampleBlock[i]); 32 | 33 | highs[0] = highs[0].AddSample(sampleBlock[i]); 34 | highs[1] = highs[1].AddSample(sampleBlock[i]); 35 | highs[2] = highs[2].AddSample(sampleBlock[i]); 36 | highs[3] = highs[3].AddSample(sampleBlock[i]); 37 | } 38 | 39 | var (fstLowIdx, sndLowIdx) = FindMaxTwo(lows); 40 | var (fstLow, sndLow) = (lows[fstLowIdx].NormResponse, lows[sndLowIdx].NormResponse); 41 | 42 | var (fstHighIdx, sndHighIdx) = FindMaxTwo(highs); 43 | var (fstHigh, sndHigh) = (highs[fstHighIdx].NormResponse, highs[sndHighIdx].NormResponse); 44 | 45 | return fstLow < threshold || fstHigh < threshold 46 | || fstLow > threshold && sndLow > threshold 47 | || fstHigh > threshold && sndHigh > threshold 48 | ? PhoneKey.None 49 | : (highTones[fstHighIdx], lowTones[fstLowIdx]).ToPhoneKey(); 50 | } 51 | 52 | private static (int fstIdx, int sndIdx) FindMaxTwo(in ReadOnlySpan goertzels) { 53 | int fst = 0, snd = 1; 54 | 55 | for (var i = 1; i < 4; i++) { 56 | if (goertzels[i].NormResponse > goertzels[fst].NormResponse) { 57 | snd = fst; 58 | fst = i; 59 | } else if (goertzels[i].NormResponse > goertzels[snd].NormResponse) { 60 | snd = i; 61 | } 62 | } 63 | 64 | return (fst, snd); 65 | } 66 | 67 | private void ResetGoertzelResponses() { 68 | lows[0] = lows[0].Reset(); 69 | lows[1] = lows[1].Reset(); 70 | lows[2] = lows[2].Reset(); 71 | lows[3] = lows[3].Reset(); 72 | 73 | highs[0] = highs[0].Reset(); 74 | highs[1] = highs[1].Reset(); 75 | highs[2] = highs[2].Reset(); 76 | highs[3] = highs[3].Reset(); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/integration/AudioFileTests.cs: -------------------------------------------------------------------------------- 1 | namespace Integration { 2 | using System; 3 | using System.Linq; 4 | using DtmfDetection; 5 | using DtmfDetection.NAudio; 6 | using NAudio.Vorbis; 7 | using NAudio.Wave; 8 | using Shouldly; 9 | using Xunit; 10 | 11 | public class AudioFileTests { 12 | [Fact] 13 | public void LongDtmfTones() { 14 | using var file = new AudioFileReader("./testdata/long_dtmf_tones.mp3"); 15 | var dtmfs = file.DtmfChanges(); 16 | dtmfs.ShouldBe(new[] { 17 | DtmfChange.Start(PhoneKey.One, TimeSpan.Parse("00:00:02.7675736"), 0), 18 | DtmfChange.Stop(PhoneKey.One, TimeSpan.Parse("00:00:05.5607029"), 0), 19 | DtmfChange.Start(PhoneKey.Two, TimeSpan.Parse("00:00:06.7138321"), 0), 20 | DtmfChange.Stop(PhoneKey.Two, TimeSpan.Parse("00:00:06.8675736"), 0), 21 | DtmfChange.Start(PhoneKey.Three, TimeSpan.Parse("00:00:07.3031972"), 0), 22 | DtmfChange.Stop(PhoneKey.Three, TimeSpan.Parse("00:00:07.4313378"), 0), 23 | DtmfChange.Start(PhoneKey.Four, TimeSpan.Parse("00:00:08.2000680"), 0), 24 | DtmfChange.Stop(PhoneKey.Four, TimeSpan.Parse("00:00:10.5319501"), 0), 25 | DtmfChange.Start(PhoneKey.Five, TimeSpan.Parse("00:00:12.0950793"), 0), 26 | DtmfChange.Stop(PhoneKey.Five, TimeSpan.Parse("00:00:12.2744444"), 0), 27 | DtmfChange.Start(PhoneKey.Six, TimeSpan.Parse("00:00:12.7357142"), 0), 28 | DtmfChange.Stop(PhoneKey.Six, TimeSpan.Parse("00:00:12.8125850"), 0), 29 | DtmfChange.Start(PhoneKey.Seven, TimeSpan.Parse("00:00:14.5038321"), 0), 30 | DtmfChange.Stop(PhoneKey.Seven, TimeSpan.Parse("00:00:14.5294557"), 0), 31 | DtmfChange.Start(PhoneKey.Seven, TimeSpan.Parse("00:00:14.5550793"), 0), 32 | DtmfChange.Stop(PhoneKey.Seven, TimeSpan.Parse("00:00:16.8357142"), 0), 33 | DtmfChange.Start(PhoneKey.Eight, TimeSpan.Parse("00:00:17.6813378"), 0), 34 | DtmfChange.Stop(PhoneKey.Eight, TimeSpan.Parse("00:00:17.7582086"), 0), 35 | DtmfChange.Start(PhoneKey.Nine, TimeSpan.Parse("00:00:18.4500680"), 0), 36 | DtmfChange.Stop(PhoneKey.Nine, TimeSpan.Parse("00:00:18.5269614"), 0), 37 | DtmfChange.Start(PhoneKey.Hash, TimeSpan.Parse("00:00:19.1163265"), 0), 38 | DtmfChange.Stop(PhoneKey.Hash, TimeSpan.Parse("00:00:19.1419501"), 0), 39 | DtmfChange.Start(PhoneKey.Hash, TimeSpan.Parse("00:00:19.1675736"), 0), 40 | DtmfChange.Stop(PhoneKey.Hash, TimeSpan.Parse("00:00:19.3469614"), 0), 41 | DtmfChange.Start(PhoneKey.Zero, TimeSpan.Parse("00:00:19.8338321"), 0), 42 | DtmfChange.Stop(PhoneKey.Zero, TimeSpan.Parse("00:00:19.8850793"), 0), 43 | DtmfChange.Start(PhoneKey.Star, TimeSpan.Parse("00:00:20.4744444"), 0), 44 | DtmfChange.Stop(PhoneKey.Star, TimeSpan.Parse("00:00:20.6025850"), 0), 45 | DtmfChange.Start(PhoneKey.One, TimeSpan.Parse("00:00:22.0119501"), 0), 46 | DtmfChange.Stop(PhoneKey.One, TimeSpan.Parse("00:00:23.7544444"), 0) 47 | }); 48 | } 49 | 50 | [Fact] 51 | public void StereoDtmfTones() { 52 | using var file = new AudioFileReader("./testdata/stereo_dtmf_tones.wav"); 53 | var dtmfs = file.DtmfChanges(forceMono: false); 54 | dtmfs.ShouldBe(new[] { 55 | DtmfChange.Start(PhoneKey.One, TimeSpan.Parse("00:00:00"), 0), 56 | DtmfChange.Stop(PhoneKey.One, TimeSpan.Parse("00:00:00.9994557"), 0), 57 | DtmfChange.Start(PhoneKey.Two, TimeSpan.Parse("00:00:01.9988208"), 1), 58 | DtmfChange.Stop(PhoneKey.Two, TimeSpan.Parse("00:00:02.9982086"), 1), 59 | DtmfChange.Start(PhoneKey.Three, TimeSpan.Parse("00:00:03.9975736"), 0), 60 | DtmfChange.Start(PhoneKey.Four, TimeSpan.Parse("00:00:04.9969614"), 1), 61 | DtmfChange.Stop(PhoneKey.Three, TimeSpan.Parse("00:00:05.9963265"), 0), 62 | DtmfChange.Stop(PhoneKey.Four, TimeSpan.Parse("00:00:06.9957142"), 1), 63 | DtmfChange.Start(PhoneKey.Five, TimeSpan.Parse("00:00:07.9950793"), 0), 64 | DtmfChange.Start(PhoneKey.Six, TimeSpan.Parse("00:00:07.9950793"), 1), 65 | DtmfChange.Stop(PhoneKey.Five, TimeSpan.Parse("00:00:08.9944444"), 0), 66 | DtmfChange.Stop(PhoneKey.Six, TimeSpan.Parse("00:00:08.9944444"), 1), 67 | DtmfChange.Start(PhoneKey.Seven, TimeSpan.Parse("00:00:09.9938321"), 0), 68 | DtmfChange.Start(PhoneKey.Eight, TimeSpan.Parse("00:00:11.0188208"), 1), 69 | DtmfChange.Stop(PhoneKey.Eight, TimeSpan.Parse("00:00:11.9925850"), 1), 70 | DtmfChange.Stop(PhoneKey.Seven, TimeSpan.Parse("00:00:12.9919501"), 0), 71 | DtmfChange.Start(PhoneKey.Nine, TimeSpan.Parse("00:00:14.0169614"), 0), 72 | DtmfChange.Stop(PhoneKey.Nine, TimeSpan.Parse("00:00:14.9907029"), 0), 73 | DtmfChange.Start(PhoneKey.Zero, TimeSpan.Parse("00:00:15.0163265"), 0), 74 | DtmfChange.Stop(PhoneKey.Zero, TimeSpan.Parse("00:00:15.9900680"), 0), 75 | }); 76 | } 77 | 78 | [Fact] 79 | public void VeryShortDtmfTones() { 80 | using var file = new VorbisWaveReader("./testdata/very_short_dtmf_tones.ogg"); 81 | var dtmfs = file.DtmfChanges(); 82 | dtmfs.Where(d => d.IsStart).Select(d => d.Key).ShouldBe(new[] 83 | { 84 | PhoneKey.Zero, 85 | PhoneKey.Six, 86 | PhoneKey.Nine, 87 | PhoneKey.Six, 88 | PhoneKey.Six, 89 | PhoneKey.Seven, 90 | PhoneKey.Five, 91 | PhoneKey.Three, 92 | PhoneKey.Five, 93 | PhoneKey.Six, 94 | 95 | PhoneKey.Four, 96 | PhoneKey.Six, 97 | PhoneKey.Four, 98 | PhoneKey.Six, 99 | PhoneKey.Four, 100 | PhoneKey.One, 101 | PhoneKey.Five, 102 | PhoneKey.One, 103 | PhoneKey.Eight, 104 | PhoneKey.Zero, 105 | 106 | PhoneKey.Two, 107 | PhoneKey.Three, 108 | PhoneKey.Three, 109 | PhoneKey.Six, 110 | PhoneKey.Seven, 111 | PhoneKey.Three, 112 | PhoneKey.One, 113 | PhoneKey.Four, 114 | PhoneKey.One, 115 | PhoneKey.Six, 116 | 117 | PhoneKey.Three, 118 | PhoneKey.Six, 119 | PhoneKey.Zero, 120 | PhoneKey.Eight, 121 | PhoneKey.Three, 122 | PhoneKey.Three, 123 | PhoneKey.Eight, 124 | PhoneKey.One, 125 | PhoneKey.Six, 126 | PhoneKey.Zero, 127 | 128 | PhoneKey.Four, 129 | PhoneKey.Four, 130 | PhoneKey.Zero, 131 | PhoneKey.Zero, 132 | PhoneKey.Eight, 133 | PhoneKey.Two, 134 | PhoneKey.Six, 135 | PhoneKey.One, 136 | PhoneKey.Four, 137 | PhoneKey.Six, 138 | 139 | PhoneKey.Six, 140 | PhoneKey.Two, 141 | PhoneKey.Five, 142 | PhoneKey.Three, 143 | PhoneKey.Six, 144 | PhoneKey.Eight, 145 | PhoneKey.Nine, 146 | PhoneKey.Six, 147 | PhoneKey.Three, 148 | PhoneKey.Eight, 149 | 150 | PhoneKey.Eight, 151 | PhoneKey.Four, 152 | PhoneKey.Eight, 153 | PhoneKey.Two, 154 | PhoneKey.One, 155 | PhoneKey.Three, 156 | PhoneKey.Eight, 157 | PhoneKey.One, 158 | PhoneKey.Seven, 159 | PhoneKey.Eight, 160 | 161 | PhoneKey.Five, 162 | PhoneKey.Zero, 163 | PhoneKey.Seven, 164 | PhoneKey.Three, 165 | PhoneKey.Six, 166 | PhoneKey.Four, 167 | PhoneKey.Three, 168 | PhoneKey.Three, 169 | PhoneKey.Nine, 170 | PhoneKey.Nine 171 | }); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /test/integration/BackgroundAnalyzerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Integration { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using DtmfDetection; 6 | using DtmfDetection.NAudio; 7 | using NAudio.Wave; 8 | using Shouldly; 9 | using Xunit; 10 | 11 | public class BackgroundAnalyzerTests { 12 | [Fact] 13 | public void DetectsDtmfTonesInShortSequence() { 14 | var dtmfs = new List(); 15 | using var waveIn = new FakeWaveIn("./testdata/short_dtmf_sequence.mp3"); 16 | using var analyzer = new BackgroundAnalyzer(waveIn, onDtmfDetected: dtmf => dtmfs.Add(dtmf)); 17 | Thread.Sleep(3000); 18 | dtmfs.Count.ShouldBe(8, string.Join("\n\t", dtmfs)); 19 | } 20 | 21 | private class FakeWaveIn : IWaveIn { 22 | public WaveFormat WaveFormat { get => reader.WaveFormat; set => throw new InvalidOperationException(); } 23 | public event EventHandler? DataAvailable; 24 | public event EventHandler? RecordingStopped; 25 | private readonly AudioFileReader reader; 26 | private bool stopRequested; 27 | 28 | public FakeWaveIn(string audioFile) => reader = new AudioFileReader(audioFile); 29 | 30 | public void StartRecording() => ThreadPool.QueueUserWorkItem(_ => Record(), null); 31 | 32 | public void StopRecording() => stopRequested = true; 33 | 34 | public void Dispose() => reader.Dispose(); 35 | 36 | private void Record() { 37 | try { 38 | while (!stopRequested && reader.CanRead) { 39 | const int n = 10000; 40 | var buffer = new byte[n]; 41 | var bytes = reader.Read(buffer, 0, n); 42 | if (bytes == 0) break; 43 | DataAvailable?.Invoke(this, new WaveInEventArgs(buffer, bytes)); 44 | Thread.Sleep(1); 45 | } 46 | } catch (Exception e) { 47 | RecordingStopped?.Invoke(this, new StoppedEventArgs(e)); 48 | } 49 | finally { 50 | RecordingStopped?.Invoke(this, new StoppedEventArgs()); 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/integration/FloatArrayTests.cs: -------------------------------------------------------------------------------- 1 | namespace Integration { 2 | using System; 3 | using System.Linq; 4 | using DtmfDetection; 5 | using Shouldly; 6 | using Xunit; 7 | 8 | using static DtmfDetection.DtmfGenerator; 9 | 10 | public class FloatArrayTests { 11 | [Fact] 12 | public void PcmData() => 13 | Concat(Space(), Mark(PhoneKey.A), Space(), Mark(PhoneKey.C), Space(), Mark(PhoneKey.A), Space(), Mark(PhoneKey.B), Space()).ToArray() 14 | .DtmfChanges() 15 | .ShouldBe(new[] { 16 | DtmfChange.Start(PhoneKey.A, TimeSpan.Parse("00:00:00.0000026"), 0), 17 | DtmfChange.Stop(PhoneKey.A, TimeSpan.Parse("00:00:00.0000051"), 0), 18 | DtmfChange.Start(PhoneKey.C, TimeSpan.Parse("00:00:00.0000077"), 0), 19 | DtmfChange.Stop(PhoneKey.C, TimeSpan.Parse("00:00:00.0000128"), 0), 20 | DtmfChange.Start(PhoneKey.A, TimeSpan.Parse("00:00:00.0000154"), 0), 21 | DtmfChange.Stop(PhoneKey.A, TimeSpan.Parse("00:00:00.0000179"), 0), 22 | DtmfChange.Start(PhoneKey.B, TimeSpan.Parse("00:00:00.0000205"), 0), 23 | DtmfChange.Stop(PhoneKey.B, TimeSpan.Parse("00:00:00.0000231"), 0) 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/integration/integration.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | enable 6 | false 7 | Integration 8 | test.integration 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | all 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | PreserveNewest 38 | 39 | 40 | PreserveNewest 41 | 42 | 43 | PreserveNewest 44 | 45 | 46 | PreserveNewest 47 | 48 | 49 | PreserveNewest 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /test/integration/testdata/long_dtmf_tones.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bert2/DtmfDetection/cbe63a91600493d0e950f0fddc4c7f645bea925d/test/integration/testdata/long_dtmf_tones.mp3 -------------------------------------------------------------------------------- /test/integration/testdata/short_dtmf_sequence.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bert2/DtmfDetection/cbe63a91600493d0e950f0fddc4c7f645bea925d/test/integration/testdata/short_dtmf_sequence.mp3 -------------------------------------------------------------------------------- /test/integration/testdata/stereo_dtmf_tones.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bert2/DtmfDetection/cbe63a91600493d0e950f0fddc4c7f645bea925d/test/integration/testdata/stereo_dtmf_tones.wav -------------------------------------------------------------------------------- /test/integration/testdata/very_short_dtmf_tones.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bert2/DtmfDetection/cbe63a91600493d0e950f0fddc4c7f645bea925d/test/integration/testdata/very_short_dtmf_tones.ogg -------------------------------------------------------------------------------- /test/integration/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplay": "method", 4 | "maxParallelThreads": 1 5 | } 6 | -------------------------------------------------------------------------------- /test/unit/AnalyzerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection; 6 | using Shouldly; 7 | using Xunit; 8 | 9 | using static DtmfDetection.DtmfGenerator; 10 | using static AnalyzerTestsExt; 11 | 12 | public class AnalyzerTests { 13 | [Fact] 14 | public void ReturnsStartAndStopOfSingleTone() => 15 | Mark(PhoneKey.Five) 16 | .Process().AndIgnorePositions() 17 | .ShouldBe(new[] { Start(PhoneKey.Five), Stop(PhoneKey.Five) }); 18 | 19 | [Fact] 20 | public void SignalsThatNoMoreDataIsAvailable() { 21 | var analyzer = Analyzer.Create( 22 | Generate(PhoneKey.Zero).Take(Config.DefaultSampleBlockSize).AsSamples(), 23 | Config.Default); 24 | _ = analyzer.AnalyzeNextBlock(); 25 | 26 | _ = analyzer.AnalyzeNextBlock(); 27 | 28 | analyzer.MoreSamplesAvailable.ShouldBeFalse(); 29 | } 30 | 31 | [Fact] 32 | public void ReturnsStopOfCutOffTones() => 33 | Mark(PhoneKey.Eight, ms: 24) 34 | .Process().AndIgnorePositions() 35 | .ShouldBe(new[] { Start(PhoneKey.Eight), Stop(PhoneKey.Eight) }); 36 | 37 | [Fact] 38 | public void ReturnsStartAndStopOfMultipleTones() => 39 | Concat(Space(), Mark(PhoneKey.A), Space(), Mark(PhoneKey.C), Space(), Mark(PhoneKey.A), Space(), Mark(PhoneKey.B), Space()) 40 | .Process().AndIgnorePositions() 41 | .ShouldBe(new[] { 42 | Start(PhoneKey.A), Stop(PhoneKey.A), 43 | Start(PhoneKey.C), Stop(PhoneKey.C), 44 | Start(PhoneKey.A), Stop(PhoneKey.A), 45 | Start(PhoneKey.B), Stop(PhoneKey.B) 46 | }); 47 | 48 | [Fact] 49 | public void ReturnsStartAndStopOfMultipleTonesAlignedWithSampleFrameSize() => 50 | Concat(Mark(PhoneKey.A, ms: 26), Mark(PhoneKey.C, ms: 26), Mark(PhoneKey.A, ms: 26), Mark(PhoneKey.B, ms: 26)) 51 | .Process().AndIgnorePositions() 52 | .ShouldBe(new[] { 53 | Start(PhoneKey.A), Stop(PhoneKey.A), 54 | Start(PhoneKey.C), Stop(PhoneKey.C), 55 | Start(PhoneKey.A), Stop(PhoneKey.A), 56 | Start(PhoneKey.B), Stop(PhoneKey.B) 57 | }); 58 | 59 | [Fact] 60 | public void ReturnsStartAndStopOfMultipleOverlappingStereoTones() => 61 | Stereo( 62 | left: Concat(Mark(PhoneKey.A, ms: 80), Space(ms: 40), Mark(PhoneKey.C, ms: 80), Space(ms: 60)), 63 | right: Concat(Space(ms: 60), Mark(PhoneKey.B, ms: 80), Space(ms: 40), Mark(PhoneKey.D, ms: 80))) 64 | .Process(channels: 2).AndIgnorePositions() 65 | .ShouldBe(new[] { 66 | // left channel // right channel 67 | Start(PhoneKey.A, 0), Start(PhoneKey.B, 1), 68 | Stop(PhoneKey.A, 0), 69 | Start(PhoneKey.C, 0), Stop(PhoneKey.B, 1), 70 | Start(PhoneKey.D, 1), 71 | Stop(PhoneKey.C, 0), Stop(PhoneKey.D, 1) 72 | }); 73 | 74 | [Fact] 75 | public void ThrowsWhenCreatedWithValidConfigButNullSamples() => new Action(() => 76 | Analyzer.Create(samples: null!, Config.Default)) 77 | .ShouldThrow(); 78 | 79 | [Fact] 80 | public void ThrowsWhenCreatedWithValidDetectorButNullSamples() => new Action(() => 81 | Analyzer.Create(samples: null!, new Detector(1, Config.Default))) 82 | .ShouldThrow(); 83 | 84 | [Fact] 85 | public void ThrowsWhenCreatedWithNullDetector() => new Action(() => 86 | Analyzer.Create(new AudioData(Array.Empty(), 1, 8000), detector: null!)) 87 | .ShouldThrow(); 88 | 89 | [Fact] 90 | public void ThrowsWhenCreatedWithMismatchingSampleRateOfConfig() => new Action(() => 91 | Analyzer.Create(new AudioData(Array.Empty(), 1, 8000), Config.Default.WithSampleRate(16000))) 92 | .ShouldThrow(); 93 | 94 | [Fact] 95 | public void ThrowsWhenCreatedWithMismatchingSampleRateOfDetector() => new Action(() => 96 | Analyzer.Create(new AudioData(Array.Empty(), 1, 8000), new Detector(1, Config.Default.WithSampleRate(16000)))) 97 | .ShouldThrow(); 98 | 99 | [Fact] 100 | public void ThrowsWhenCreatedWithMismatchingChannelsOfDetector() => new Action(() => 101 | Analyzer.Create(new AudioData(Array.Empty(), 1, 8000), new Detector(2, Config.Default))) 102 | .ShouldThrow(); 103 | } 104 | 105 | public static class AnalyzerTestsExt { 106 | public static List Process(this IEnumerable samples, int channels = 1) { 107 | var analyzer = Analyzer.Create(samples.AsSamples(channels), Config.Default); 108 | 109 | var dtmfs = new List(); 110 | while (analyzer.MoreSamplesAvailable) dtmfs.AddRange(analyzer.AnalyzeNextBlock()); 111 | 112 | return dtmfs; 113 | } 114 | 115 | public static IEnumerable AndIgnorePositions(this IEnumerable dtmfs) => dtmfs 116 | .Select(x => new DtmfChange(x.Key, new TimeSpan(), x.Channel, x.IsStart)); 117 | 118 | public static DtmfChange Start(PhoneKey k, int channel = 0) => DtmfChange.Start(k, new TimeSpan(), channel); 119 | 120 | public static DtmfChange Stop(PhoneKey k, int channel = 0) => DtmfChange.Stop(k, new TimeSpan(), channel); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/unit/ConfigTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System.Collections.Generic; 3 | using DtmfDetection; 4 | using Shouldly; 5 | using Xunit; 6 | 7 | public class ConfigTests { 8 | [Fact] 9 | public void CanConfigureThreshold() => 10 | new Config(1, 2, 3, true) 11 | .WithThreshold(4) 12 | .ShouldBe(new Config(4, 2, 3, true)); 13 | 14 | [Fact] 15 | public void CanConfigureSampleBlockSize() => 16 | new Config(1, 2, 3, true) 17 | .WithSampleBlockSize(4) 18 | .ShouldBe(new Config(1, 4, 3, true)); 19 | 20 | [Fact] 21 | public void CanConfigureSampleRate() => 22 | new Config(1, 2, 3, true) 23 | .WithSampleRate(4) 24 | .ShouldBe(new Config(1, 2, 4, true)); 25 | 26 | [Fact] 27 | public void CanConfigureResponseNormalization() => 28 | new Config(1, 2, 3, true) 29 | .WithNormalizeResponse(false) 30 | .ShouldBe(new Config(1, 2, 3, false)); 31 | 32 | #region Equality implementations 33 | 34 | [Fact] 35 | public void ImplementsIEquatable() => 36 | new HashSet { new Config(1, 2, 3, true) } 37 | .Contains(new Config(1, 2, 3, true)) 38 | .ShouldBeTrue(); 39 | 40 | [Fact] 41 | public void OverridesGetHashCode() => 42 | new Config(1, 2, 3, true).GetHashCode() 43 | .ShouldNotBe(new Config(4, 5, 6, false).GetHashCode()); 44 | 45 | [Fact] 46 | public void OverridesEquals() => 47 | new Config(1, 2, 3, true) 48 | .Equals((object)new Config(1, 2, 3, true)) 49 | .ShouldBeTrue(); 50 | 51 | [Fact] 52 | public void OverridesEqualsOperator() => 53 | (new Config(1, 2, 3, true) == new Config(1, 2, 3, true)) 54 | .ShouldBeTrue(); 55 | 56 | [Fact] 57 | public void OverridesNotEqualsOperator() => 58 | (new Config(1, 2, 3, true) != new Config(4, 5, 6, false)) 59 | .ShouldBeTrue(); 60 | 61 | #endregion Equality implementations 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/unit/DetectorTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection; 6 | using MoreLinq; 7 | using Shouldly; 8 | using Xunit; 9 | 10 | using static DtmfDetection.DtmfGenerator; 11 | using static DtmfDetection.Utils; 12 | using static DetectorTestsExt; 13 | 14 | public class DetectorTests { 15 | [Fact] 16 | public void DetectsAllPhoneKeys() => PhoneKeys().ForEach(key => 17 | DtmfToneBlock(key) 18 | .Analyze() 19 | .ShouldBe(new[] { key })); 20 | 21 | [Fact] 22 | public void CanBeReused() => 23 | new Detector(1, Config.Default).With(d => _ = d.Detect(DtmfToneBlock(PhoneKey.Three))) 24 | .Detect(DtmfToneBlock(PhoneKey.C)) 25 | .ShouldBe(new[] { PhoneKey.C }); 26 | 27 | [Fact] 28 | public void SupportsStereo() => 29 | Generate(PhoneKey.One).Interleave(Generate(PhoneKey.Two)).FirstBlock(channels: 2) 30 | .Analyze(channels: 2) 31 | .ShouldBe(new[] { PhoneKey.One, PhoneKey.Two }); 32 | 33 | [Fact] 34 | public void SupportsQuadChannel() => 35 | Generate(PhoneKey.One).Interleave(Generate(PhoneKey.Two), Generate(PhoneKey.Three), Generate(PhoneKey.Four)).FirstBlock(channels: 4) 36 | .Analyze(channels: 4) 37 | .ShouldBe(new[] { PhoneKey.One, PhoneKey.Two, PhoneKey.Three, PhoneKey.Four }); 38 | } 39 | 40 | public static class DetectorTestsExt { 41 | public static object Analyze(this float[] samples, int channels = 1) 42 | => new Detector(channels, Config.Default).Detect(samples); 43 | 44 | public static T With(this T x, Action action) { action?.Invoke(x); return x; } 45 | 46 | public static float[] DtmfToneBlock(PhoneKey k) => Generate(k).FirstBlock(); 47 | 48 | public static float[] FirstBlock(this IEnumerable samples, int channels = 1, int blockSize = Config.DefaultSampleBlockSize) 49 | => samples.Take(blockSize * channels).ToArray(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/unit/DtmfChangeTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using DtmfDetection; 5 | using Shouldly; 6 | using Xunit; 7 | 8 | public class DtmfChangeTests { 9 | [Fact] 10 | public void ToStringPrintsInfoLine() => 11 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3) 12 | .ToString() 13 | .ShouldBe("B started @ 00:00:05 (ch: 3)"); 14 | 15 | #region Equality implementations 16 | 17 | [Fact] 18 | public void ImplementsIEquatable() => 19 | new HashSet { DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3) } 20 | .Contains(DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3)) 21 | .ShouldBeTrue(); 22 | 23 | [Fact] 24 | public void OverridesGetHashCode() => 25 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3).GetHashCode() 26 | .ShouldNotBe(DtmfChange.Stop(PhoneKey.C, TimeSpan.FromSeconds(2), 4).GetHashCode()); 27 | 28 | [Fact] 29 | public void OverridesEquals() => 30 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3) 31 | .Equals((object)DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3)) 32 | .ShouldBeTrue(); 33 | 34 | [Fact] 35 | public void OverridesEqualsOperator() => 36 | (DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3) 37 | == DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3)) 38 | .ShouldBeTrue(); 39 | 40 | [Fact] 41 | public void OverridesNotEqualsOperator() => 42 | (DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(5), 3) 43 | != DtmfChange.Stop(PhoneKey.C, TimeSpan.FromSeconds(2), 4)) 44 | .ShouldBeTrue(); 45 | 46 | #endregion Equality implementations 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/unit/DtmfToneTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using DtmfDetection; 5 | using Shouldly; 6 | using Xunit; 7 | 8 | public class DtmfToneTests { 9 | [Fact] 10 | public void ToStringPrintsInfoLine() => 11 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3) 12 | .ToString() 13 | .ShouldBe("B @ 00:00:05 (len: 00:00:00.0400000, ch: 3)"); 14 | 15 | #region Equality implementations 16 | 17 | [Fact] 18 | public void ImplementsIEquatable() => 19 | new HashSet { new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3) } 20 | .Contains(new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3)) 21 | .ShouldBeTrue(); 22 | 23 | [Fact] 24 | public void OverridesGetHashCode() => 25 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3).GetHashCode() 26 | .ShouldNotBe(new DtmfTone(PhoneKey.C, TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(30), 4).GetHashCode()); 27 | 28 | [Fact] 29 | public void OverridesEquals() => 30 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3) 31 | .Equals((object)new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3)) 32 | .ShouldBeTrue(); 33 | 34 | [Fact] 35 | public void OverridesEqualsOperator() => 36 | (new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3) 37 | == new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3)) 38 | .ShouldBeTrue(); 39 | 40 | [Fact] 41 | public void OverridesNotEqualsOperator() => 42 | (new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(40), 3) 43 | != new DtmfTone(PhoneKey.C, TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(30), 4)) 44 | .ShouldBeTrue(); 45 | 46 | #endregion Equality implementations 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/unit/GoertzelTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection; 6 | using Shouldly; 7 | using Xunit; 8 | 9 | using static DtmfDetection.DtmfGenerator; 10 | 11 | public class GoertzelTests { 12 | [Theory] 13 | [InlineData(697)] 14 | [InlineData(770)] 15 | [InlineData(852)] 16 | [InlineData(941)] 17 | public void CanDetectAllLowTones(int freq) => 18 | Sine(freq) 19 | .MeasureFrequency(freq) 20 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold); 21 | 22 | [Theory] 23 | [InlineData(1209)] 24 | [InlineData(1336)] 25 | [InlineData(1477)] 26 | [InlineData(1633)] 27 | public void CanDetectAllHighTones(int freq) => 28 | Sine(freq) 29 | .MeasureFrequency(freq) 30 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold); 31 | 32 | [Fact] 33 | public void DoesNotDetectFrequencyInConstantSignal() => 34 | Constant(.3f) 35 | .MeasureFrequency(1209) 36 | .GoertzelResponseShouldBeLessThan(Config.Default.Threshold); 37 | 38 | [Fact] 39 | public void CanDetectWeakFrequency() => 40 | Sine(1209, amplitude: .1f) 41 | .MeasureFrequency(1209) 42 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold); 43 | 44 | [Fact] 45 | public void CanDetectFrequencyInNoisySignal() => Repeat(() => 46 | Sine(1336).Add(Noise(.1f)).Normalize(1.1f) 47 | .MeasureFrequency(1336) 48 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold)); 49 | 50 | [Fact] 51 | public void CanDetectFrequencyInVeryNoisySignal() => Repeat(() => 52 | Sine(941).Add(Noise(.5f)).Normalize(1.5f) 53 | .MeasureFrequency(941) 54 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold)); 55 | 56 | [Fact] 57 | public void CanDetectFrequencyInOverlappingFrequency() => 58 | Sine(697).Add(Sine(1633)).Normalize(2) 59 | .MeasureFrequency(697) 60 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold); 61 | 62 | [Fact] 63 | public void CanDetectWeakFrequencyInOverlappingFrequencyAndNoisySignal() => Repeat(() => 64 | Sine(1477, amplitude: .4f).Add(Sine(852, amplitude: .4f)).Add(Noise(.1f)) 65 | .MeasureFrequency(1477) 66 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold)); 67 | 68 | [Fact] 69 | public void CanDetectFrequencyThatStartsLate() => 70 | Constant(.0f).Take(102).Concat(Sine(1336)) 71 | .MeasureFrequency(1336) 72 | .GoertzelResponseShouldBeGreaterThan(Config.Default.Threshold); 73 | 74 | [Fact] 75 | public void CanBeResetted() => 76 | Sine(1336).MeasureFrequency(1336).Reset() 77 | .Response 78 | .ShouldBe(0); 79 | 80 | [Fact] 81 | public void AlsoResetsTotalEnergy() => 82 | Sine(1336).MeasureFrequency(1336).Reset() 83 | .NormResponse 84 | .ShouldBe(double.NaN); 85 | 86 | [Fact] 87 | public void ToStringPrintsNormalizedResponse() => 88 | new Goertzel(1, 2, 3, 4) 89 | .ToString() 90 | .ShouldBe("1.75"); 91 | 92 | #region Equality implementations 93 | 94 | [Fact] 95 | public void ImplementsIEquatable() => 96 | new HashSet { new Goertzel(1, 2, 3, 4) } 97 | .Contains(new Goertzel(1, 2, 3, 4)) 98 | .ShouldBeTrue(); 99 | 100 | [Fact] 101 | public void OverridesGetHashCode() => 102 | new Goertzel(1, 2, 3, 4).GetHashCode() 103 | .ShouldNotBe(new Goertzel(5, 6, 7, 8).GetHashCode()); 104 | 105 | [Fact] 106 | public void OverridesEquals() => 107 | new Goertzel(1, 2, 3, 4) 108 | .Equals((object)new Goertzel(1, 2, 3, 4)) 109 | .ShouldBeTrue(); 110 | 111 | [Fact] 112 | public void OverridesEqualsOperator() => 113 | (new Goertzel(1, 2, 3, 4) == new Goertzel(1, 2, 3, 4)) 114 | .ShouldBeTrue(); 115 | 116 | [Fact] 117 | public void OverridesNotEqualsOperator() => 118 | (new Goertzel(1, 2, 3, 4) != new Goertzel(5, 6, 7, 8)) 119 | .ShouldBeTrue(); 120 | 121 | #endregion Equality implementations 122 | 123 | private static void Repeat(Action test, int count = 1000) { 124 | for (var i = 1; i <= count; i++) { 125 | try { 126 | test(); 127 | } catch (ShouldAssertException e) { 128 | throw new ShouldAssertException($"Failed at try #{i}:", e); 129 | } 130 | } 131 | } 132 | } 133 | 134 | public static class GoertzelTestsExt { 135 | public static Goertzel MeasureFrequency(this IEnumerable samples, int targetFreq) => samples 136 | .Take(Config.Default.SampleBlockSize) 137 | .Aggregate( 138 | Goertzel.Init(targetFreq, Config.Default.SampleRate, Config.Default.SampleBlockSize), 139 | (g, s) => g.AddSample(s)); 140 | 141 | public static void GoertzelResponseShouldBeGreaterThan(this in Goertzel g, double threshold) 142 | => g.NormResponse.ShouldBeGreaterThan(threshold, g.ToMessage()); 143 | 144 | public static void GoertzelResponseShouldBeLessThan(this in Goertzel g, double threshold) 145 | => g.NormResponse.ShouldBeLessThan(threshold, g.ToMessage()); 146 | 147 | private static string ToMessage(this in Goertzel g) => 148 | $"response:\t\t\t\t{g.Response, 8:#.###}\n" 149 | + $"\tnormalized response:\t{g.NormResponse, 8:#.###}\n" 150 | + $"\ttotal signal energy:\t{g.Response / g.NormResponse, 8:#.###}"; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /test/unit/PhoneKeyTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using DtmfDetection; 3 | using MoreLinq; 4 | using Shouldly; 5 | using Xunit; 6 | 7 | using static DtmfDetection.Utils; 8 | 9 | public class PhoneKeyTests { 10 | [Fact] 11 | public void ConversionToDtmfToneIsInvertible() => PhoneKeys().ForEach(key => 12 | key 13 | .ToDtmfTone().ToPhoneKey() 14 | .ShouldBe(key)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/unit/ToDtmfTonesTests.cs: -------------------------------------------------------------------------------- 1 | namespace Unit { 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using DtmfDetection; 6 | using Shouldly; 7 | using Xunit; 8 | 9 | public class ToDtmfTonesTests { 10 | [Fact] 11 | public void MergesStartsAndStopsIntoDtmfTones() => 12 | new List { 13 | DtmfChange.Start(PhoneKey.A, TimeSpan.FromSeconds(1), 0), 14 | DtmfChange.Stop(PhoneKey.A, TimeSpan.FromSeconds(3), 0), 15 | 16 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(4), 0), 17 | DtmfChange.Stop(PhoneKey.B, TimeSpan.FromSeconds(7), 0), 18 | 19 | DtmfChange.Start(PhoneKey.C, TimeSpan.FromSeconds(9), 0), 20 | DtmfChange.Stop(PhoneKey.C, TimeSpan.FromSeconds(10), 0) 21 | } 22 | .ToDtmfTones() 23 | .ShouldBe(new[] { 24 | new DtmfTone(PhoneKey.A, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), 0), 25 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(3), 0), 26 | new DtmfTone(PhoneKey.C, TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(1), 0) 27 | }); 28 | 29 | [Fact] 30 | public void CanHandleOverlapInSameChannel() => 31 | new List { 32 | DtmfChange.Start(PhoneKey.A, TimeSpan.FromSeconds(1), 0), 33 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(2), 0), 34 | DtmfChange.Stop(PhoneKey.B, TimeSpan.FromSeconds(3), 0), 35 | DtmfChange.Start(PhoneKey.C, TimeSpan.FromSeconds(4), 0), 36 | DtmfChange.Stop(PhoneKey.A, TimeSpan.FromSeconds(5), 0), 37 | DtmfChange.Stop(PhoneKey.C, TimeSpan.FromSeconds(6), 0) 38 | } 39 | .ToDtmfTones() 40 | .ShouldBe(new[] { 41 | new DtmfTone(PhoneKey.A, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4), 0), 42 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1), 0), 43 | new DtmfTone(PhoneKey.C, TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(2), 0) 44 | }); 45 | 46 | [Fact] 47 | public void CanHandleOverlapAcrossChannels() => 48 | new List { 49 | DtmfChange.Start(PhoneKey.A, TimeSpan.FromSeconds(1), 0), 50 | DtmfChange.Start(PhoneKey.B, TimeSpan.FromSeconds(2), 1), 51 | DtmfChange.Stop(PhoneKey.B, TimeSpan.FromSeconds(3), 1), 52 | DtmfChange.Start(PhoneKey.C, TimeSpan.FromSeconds(4), 2), 53 | DtmfChange.Stop(PhoneKey.A, TimeSpan.FromSeconds(5), 0), 54 | DtmfChange.Stop(PhoneKey.C, TimeSpan.FromSeconds(6), 2) 55 | } 56 | .ToDtmfTones() 57 | .ShouldBe(new[] { 58 | new DtmfTone(PhoneKey.A, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4), 0), 59 | new DtmfTone(PhoneKey.B, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1), 1), 60 | new DtmfTone(PhoneKey.C, TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(2), 2) 61 | }); 62 | 63 | [Fact] 64 | public void ThrowsWhenStopIsMissing() => new Action(() => _ = 65 | new List { 66 | DtmfChange.Start(PhoneKey.A, TimeSpan.FromSeconds(1), 0) 67 | } 68 | .ToDtmfTones().ToList()) 69 | .ShouldThrow(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/unit/unit.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | 8.0 6 | enable 7 | false 8 | Unit 9 | test.unit 10 | 11 | 12 | 13 | 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | all 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | all 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | 29 | 30 | all 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | PreserveNewest 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /test/unit/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplay": "method" 4 | } 5 | --------------------------------------------------------------------------------