├── src ├── app │ ├── app.component.scss │ ├── home │ │ ├── home.page.scss │ │ ├── components │ │ │ └── exercise-summary │ │ │ │ ├── exercise-summary.component.scss │ │ │ │ ├── exercise-summary.component.html │ │ │ │ └── exercise-summary.component.ts │ │ ├── home-routing.module.ts │ │ ├── home.page.ts │ │ ├── home.module.ts │ │ ├── home.page.spec.ts │ │ └── home.page.html │ ├── about │ │ ├── about.page.scss │ │ ├── about.page.ts │ │ └── about.module.ts │ ├── sandbox │ │ ├── sandbox.component.scss │ │ └── sandbox.component.html │ ├── shared │ │ ├── modal │ │ │ ├── modal-frame │ │ │ │ ├── modal-frame.component.scss │ │ │ │ ├── modal-frame.component.html │ │ │ │ └── modal-frame.component.ts │ │ │ └── modal.module.ts │ │ ├── animations │ │ │ ├── index.ts │ │ │ ├── fade.ts │ │ │ └── collapse-vertical.ts │ │ ├── ts-utility │ │ │ ├── AtLeastOne.ts │ │ │ ├── PublicMembers.ts │ │ │ ├── Constructor.ts │ │ │ ├── Primitive.ts │ │ │ ├── mod.ts │ │ │ ├── ArrayItemType.ts │ │ │ ├── rxjs │ │ │ │ ├── index.ts │ │ │ │ ├── SyncOrAsync.ts │ │ │ │ ├── toObservable.ts │ │ │ │ ├── tapLogValue.ts │ │ │ │ ├── toPromise.ts │ │ │ │ ├── shareReplayUntil.ts │ │ │ │ ├── observable-spy │ │ │ │ │ └── observable-spy-matchers.ts │ │ │ │ └── publishReplayUntilAndConnect.ts │ │ │ ├── isValueTruthy.ts │ │ │ ├── randomFromList.ts │ │ │ ├── toArray.ts │ │ │ ├── timeoutAsPromise.ts │ │ │ ├── DeepReadonly.ts │ │ │ ├── base-classes │ │ │ │ ├── index.ts │ │ │ │ ├── base-destroyable.ts │ │ │ │ └── base-component.ts │ │ │ ├── StaticOrGetter.ts │ │ │ ├── index.ts │ │ │ ├── mod.spec.ts │ │ │ ├── collectionChain.ts │ │ │ ├── LogReturnValue.ts │ │ │ └── SubjectPromise.ts │ │ ├── components │ │ │ └── shared-components │ │ │ │ ├── info-panel │ │ │ │ ├── info-panel.component.html │ │ │ │ ├── info-panel.component.scss │ │ │ │ └── info-panel.component.ts │ │ │ │ ├── collapsible │ │ │ │ ├── collapsible.component.scss │ │ │ │ ├── collapsible.component.html │ │ │ │ └── collapsible.component.ts │ │ │ │ ├── content-padding.directive.ts │ │ │ │ ├── shared-components.module.ts │ │ │ │ └── play-on-click.directive.ts │ │ ├── testing-utility │ │ │ ├── index.ts │ │ │ ├── jasmine │ │ │ │ └── custom-matchers │ │ │ │ │ ├── spy-matchers │ │ │ │ │ └── spy-matchers.ts │ │ │ │ │ ├── utility.ts │ │ │ │ │ └── init-custom-matchers.ts │ │ │ ├── testPureFunction.ts │ │ │ ├── BaseComponentDebugger.ts │ │ │ └── TestingUtility.ts │ │ ├── ng-utilities │ │ │ ├── pure-function-pipe │ │ │ │ ├── pure-function.pipe.ts │ │ │ │ └── pure-function-pipe.module.ts │ │ │ └── console-log-component │ │ │ │ ├── console-log.component.ts │ │ │ │ └── console-log-component.module.ts │ │ ├── reactive-forms │ │ │ ├── index.ts │ │ │ └── ControlValueAccessor.ts │ │ └── ionic-testing │ │ │ ├── ionic-testing.module.ts │ │ │ └── services │ │ │ ├── alert-controller.mock.ts │ │ │ ├── modal-controller.mock.ts │ │ │ └── toaster-controller.mock.ts │ ├── app.component.html │ ├── exercise │ │ ├── utility │ │ │ ├── music │ │ │ │ ├── harmony │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── romanNumeralToChordInC.ts │ │ │ │ │ ├── getRelativeKeyTonic.spec.ts │ │ │ │ │ ├── getRelativeKeyTonic.ts │ │ │ │ │ ├── Mode.ts │ │ │ │ │ ├── Mode.spec.ts │ │ │ │ │ └── RomanNumeralChordSymbol.ts │ │ │ │ ├── notes │ │ │ │ │ ├── consts.ts │ │ │ │ │ ├── getNoteOctave.ts │ │ │ │ │ ├── noteTypeToNote.ts │ │ │ │ │ ├── getNoteType.ts │ │ │ │ │ ├── toNoteType.spec.ts │ │ │ │ │ ├── getNoteOctave.spec.ts │ │ │ │ │ ├── NoteType.ts │ │ │ │ │ ├── NoteType.spec.ts │ │ │ │ │ ├── NoteNumberOrName.ts │ │ │ │ │ ├── toNoteTypeNumber.ts │ │ │ │ │ ├── toNoteName.spec.ts │ │ │ │ │ └── toNoteTypeNumber.spec.ts │ │ │ │ ├── keys │ │ │ │ │ ├── Key.ts │ │ │ │ │ ├── getDistanceOfKeys.spec.ts │ │ │ │ │ ├── isInKey.spec.ts │ │ │ │ │ ├── getDistanceOfKeys.ts │ │ │ │ │ └── isInKey.ts │ │ │ │ ├── MusicSymbol.ts │ │ │ │ ├── scale-degrees │ │ │ │ │ ├── SolfegeNote.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── scaleDegreeToNoteType.ts │ │ │ │ │ ├── noteTypeToScaleDegree.ts │ │ │ │ │ ├── scaleDegreeToNoteType.spec.ts │ │ │ │ │ ├── scaleDegreeToSolfegeNote.ts │ │ │ │ │ ├── noteTypeToScaleDegree.spec.ts │ │ │ │ │ └── ScaleDegrees.spec.ts │ │ │ │ ├── chords │ │ │ │ │ └── index.ts │ │ │ │ ├── intervals │ │ │ │ │ ├── getInterval.ts │ │ │ │ │ └── Interval.ts │ │ │ │ ├── index.ts │ │ │ │ ├── getMusicTextDisplay.ts │ │ │ │ ├── getMusicTextDisplay.spec.ts │ │ │ │ └── toSteadyPart.ts │ │ │ ├── index.ts │ │ │ └── exercise-settings │ │ │ │ ├── ExerciseSettingsData.ts │ │ │ │ └── GlobalExerciseSettings.ts │ │ ├── exercise.page │ │ │ ├── components │ │ │ │ ├── exercise-settings.page │ │ │ │ │ ├── exercise-settings.page.scss │ │ │ │ │ └── components │ │ │ │ │ │ ├── list-select │ │ │ │ │ │ ├── list-select.component.scss │ │ │ │ │ │ └── list-select.component.html │ │ │ │ │ │ ├── included-answers │ │ │ │ │ │ ├── included-answers.component.scss │ │ │ │ │ │ ├── included-answers.component.html │ │ │ │ │ │ └── included-answers.component.ts │ │ │ │ │ │ └── field-info │ │ │ │ │ │ ├── field-info.component.scss │ │ │ │ │ │ ├── field-info.component.html │ │ │ │ │ │ └── field-info.component.ts │ │ │ │ ├── answer-indication │ │ │ │ │ ├── answer-indication.component.html │ │ │ │ │ ├── answer-indication.component.scss │ │ │ │ │ └── answer-indication.component.ts │ │ │ │ ├── exercise-help │ │ │ │ │ └── exercise-explanation │ │ │ │ │ │ ├── exercise-explanation.page.scss │ │ │ │ │ │ ├── exercise-explanation.page.html │ │ │ │ │ │ ├── exercise-explanation.page.ts │ │ │ │ │ │ └── exercise-explanation-content.directive.ts │ │ │ │ └── answers-layout │ │ │ │ │ ├── answers-layout.component.scss │ │ │ │ │ └── answers-layout.component.ts │ │ │ ├── state │ │ │ │ └── adaptive-exercise.service.ts │ │ │ └── utility │ │ │ │ └── getCurrentAnswersLayout.ts │ │ ├── exercises │ │ │ ├── utility │ │ │ │ ├── settings │ │ │ │ │ ├── keySelectionSettingsDescriptors.spec.ts │ │ │ │ │ ├── withSettings.ts │ │ │ │ │ ├── SettingsParams.ts │ │ │ │ │ ├── PlayAfterCorrectAnswerSetting.ts │ │ │ │ │ └── NumberOfSegmentsSetting.ts │ │ │ │ ├── exerciseAttributes │ │ │ │ │ ├── chordProgressionExercise.spec.ts │ │ │ │ │ ├── defaultSettings.ts │ │ │ │ │ └── composeExercise.ts │ │ │ │ └── answer-layouts │ │ │ │ │ └── scale-layout.ts │ │ │ ├── NotesWithChords │ │ │ │ ├── notes-with-chords-explanation │ │ │ │ │ ├── notes-with-chords-explanation.component.ts │ │ │ │ │ └── notes-with-chords-explanation.component.html │ │ │ │ └── notesWithChordsExercise.spec.ts │ │ │ ├── CommonChordProgressionExercise │ │ │ │ └── common-chord-progressions-explanation │ │ │ │ │ ├── common-chord-progressions-explanation.component.ts │ │ │ │ │ └── common-chord-progressions-explanation.component.html │ │ │ ├── TriadInversionExercise │ │ │ │ ├── triad-inversion-explanation │ │ │ │ │ ├── triad-inversion-explanation.component.ts │ │ │ │ │ └── triad-inversion-explanation.component.html │ │ │ │ └── triadInversionExercise.spec.ts │ │ │ ├── NotesInKeyExercise │ │ │ │ └── notes-in-key-explanation │ │ │ │ │ └── notes-in-key-explanation.component.ts │ │ │ ├── IntervalExercise │ │ │ │ └── interval-exercise-explanation │ │ │ │ │ ├── interval-exercise-explanation.component.html │ │ │ │ │ └── interval-exercise-explanation.component.ts │ │ │ ├── ChordTypeInKeyExercise │ │ │ │ ├── chord-type-in-key-explanation │ │ │ │ │ └── chord-type-in-key-explanation.component.html │ │ │ │ └── chordTypeInKeyExercise.spec.ts │ │ │ └── ChordInKeyExercise │ │ │ │ └── chord-in-key-explanation │ │ │ │ └── chord-in-key-explanation.component.ts │ │ ├── exercise-routing.module.ts │ │ ├── ExerciseTest.ts │ │ └── exercise.mock.service.ts │ ├── release-notes │ │ ├── release-notes-page.component.scss │ │ ├── release-notes.testing.module.ts │ │ ├── release-notes-page.component.html │ │ ├── version-comparator.ts │ │ ├── release-notes-page.component.ts │ │ ├── version-comparator.spec.ts │ │ ├── release-notes.module.ts │ │ └── release-notes.service.mock.ts │ ├── storage │ │ ├── storage.module.ts │ │ ├── migration-scripts │ │ │ ├── migration-scripts.ts │ │ │ ├── storage-migration-1.3.2.ts │ │ │ └── storage-migration-1.3.2.spec.ts │ │ ├── storage.testing.module.ts │ │ ├── storage.service.ts │ │ ├── storage.service.mock.ts │ │ └── storage-migration.service.mock.ts │ ├── services │ │ ├── player.service.spec.ts │ │ ├── drone-player.service.spec.ts │ │ ├── drone-player.service.ts │ │ ├── exercise-settings-data.mock.service.ts │ │ ├── player.mock.service.ts │ │ └── exercise-settings-data.service.ts │ ├── view-message │ │ ├── view-message-routing.module.ts │ │ ├── view-message.module.ts │ │ ├── view-message.page.scss │ │ ├── view-message.page.ts │ │ ├── view-message.page.spec.ts │ │ └── view-message.page.html │ ├── version.service.mock.ts │ ├── version.service.ts │ ├── app-routing.module.ts │ └── app.component.spec.ts ├── style │ ├── main.scss │ └── overrides │ │ ├── html.scss │ │ ├── ion-item.scss │ │ ├── ion-list.scss │ │ ├── ion-title.scss │ │ ├── index.scss │ │ ├── ion-button.scss │ │ ├── ion-card.scss │ │ ├── ion-content.scss │ │ └── bdc-walk-popup.scss ├── assets │ ├── splash.png │ ├── icon │ │ └── favicon.png │ └── shapes.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── zone-flags.ts ├── main.ts ├── test.ts └── global.scss ├── android ├── app │ ├── src │ │ ├── main │ │ │ ├── assets │ │ │ │ ├── .gitignore │ │ │ │ ├── capacitor.config.json │ │ │ │ └── capacitor.plugins.json │ │ │ ├── res │ │ │ │ ├── drawable │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-land-hdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-land-mdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-port-hdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-port-mdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-land-xhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-land-xxhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-port-xhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-port-xxhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-land-xxxhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── drawable-port-xxxhdpi │ │ │ │ │ └── splash.png │ │ │ │ ├── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ ├── strings.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── xml │ │ │ │ │ ├── file_paths.xml │ │ │ │ │ └── config.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ └── layout │ │ │ │ │ └── activity_main.xml │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── openear │ │ │ │ └── www │ │ │ │ └── MainActivity.java │ │ ├── test │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── getcapacitor │ │ │ │ └── myapp │ │ │ │ └── ExampleUnitTest.java │ │ └── androidTest │ │ │ └── java │ │ │ └── com │ │ │ └── getcapacitor │ │ │ └── myapp │ │ │ └── ExampleInstrumentedTest.java │ ├── .gitignore │ ├── capacitor.build.gradle │ └── proguard-rules.pro ├── .idea │ ├── .gitignore │ ├── compiler.xml │ └── misc.xml ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── settings.gradle ├── variables.gradle ├── build.gradle └── capacitor.settings.gradle ├── .monaca ├── local_properties.json └── project_info.json ├── resources ├── icon.png ├── splash.png ├── android │ ├── icon.png │ ├── icon-background.png │ ├── icon-foreground.png │ └── xml │ │ └── network_security_config.xml ├── ios │ ├── icon │ │ ├── icon.png │ │ ├── icon-20.png │ │ ├── icon-29.png │ │ ├── icon-40.png │ │ ├── icon-50.png │ │ ├── icon-60.png │ │ ├── icon-72.png │ │ ├── icon-76.png │ │ ├── icon@2x.png │ │ ├── icon-1024.png │ │ ├── icon-108@2x.png │ │ ├── icon-20@2x.png │ │ ├── icon-20@3x.png │ │ ├── icon-24@2x.png │ │ ├── icon-29@2x.png │ │ ├── icon-29@3x.png │ │ ├── icon-40@2x.png │ │ ├── icon-40@3x.png │ │ ├── icon-44@2x.png │ │ ├── icon-50@2x.png │ │ ├── icon-60@2x.png │ │ ├── icon-60@3x.png │ │ ├── icon-72@2x.png │ │ ├── icon-76@2x.png │ │ ├── icon-86@2x.png │ │ ├── icon-98@2x.png │ │ ├── icon-small.png │ │ ├── icon-27.5@2x.png │ │ ├── icon-83.5@2x.png │ │ ├── icon-small@2x.png │ │ └── icon-small@3x.png │ └── splash │ │ ├── Default-2436h.png │ │ ├── Default-667h.png │ │ ├── Default-736h.png │ │ ├── Default~iphone.png │ │ ├── Default@2x~iphone.png │ │ ├── Default-1792h~iphone.png │ │ ├── Default-2688h~iphone.png │ │ ├── Default-Portrait~ipad.png │ │ ├── Default-568h@2x~iphone.png │ │ ├── Default-Landscape-2436h.png │ │ ├── Default-Landscape-736h.png │ │ ├── Default-Landscape~ipad.png │ │ ├── Default-Portrait@2x~ipad.png │ │ ├── Default-Landscape@2x~ipad.png │ │ ├── Default-Landscape@~ipadpro.png │ │ ├── Default-Portrait@~ipadpro.png │ │ ├── Default@2x~universal~anyany.png │ │ ├── Default-Landscape-1792h~iphone.png │ │ └── Default-Landscape-2688h~iphone.png └── README.md ├── res ├── ios │ ├── icon │ │ ├── icon.png │ │ ├── icon-20.png │ │ ├── icon-40.png │ │ ├── icon-50.png │ │ ├── icon-60.png │ │ ├── icon-72.png │ │ ├── icon-76.png │ │ ├── icon@2x.png │ │ ├── icon-1024.png │ │ ├── icon-20@2x.png │ │ ├── icon-40@2x.png │ │ ├── icon-50@2x.png │ │ ├── icon-60@2x.png │ │ ├── icon-60@3x.png │ │ ├── icon-72@2x.png │ │ ├── icon-76@2x.png │ │ ├── icon-small.png │ │ ├── icon-small@2x.png │ │ ├── icon-small@3x.png │ │ └── icon-83.5@2x~ipad.png │ └── screen │ │ ├── Default-667h.png │ │ ├── Default-736h.png │ │ ├── Default~iphone.png │ │ ├── Default@2x~iphone.png │ │ ├── Default-568h@2x~iphone.png │ │ ├── Default-Landscape-736h.png │ │ ├── Default-Landscape~ipad.png │ │ ├── Default-Portrait~ipad.png │ │ ├── Default-Landscape@2x~ipad.png │ │ ├── Default-Portrait@2x~ipad.png │ │ └── Default@2x~universal~anyany.png ├── android │ ├── icon │ │ ├── hdpi.png │ │ ├── ldpi.png │ │ ├── mdpi.png │ │ ├── xhdpi.png │ │ ├── xxhdpi.png │ │ └── xxxhdpi.png │ └── screen │ │ ├── splash-mdpi.png │ │ ├── splash-port-hdpi.9.png │ │ ├── splash-port-ldpi.9.png │ │ ├── splash-port-mdpi.9.png │ │ ├── splash-port-xhdpi.9.png │ │ ├── splash-port-xxhdpi.9.png │ │ └── splash-port-xxxhdpi.9.png └── electron │ ├── icon │ └── icon_electron_512.png │ └── screen │ └── electron_splash_image.png ├── tsconfig-node.json ├── ios ├── App │ ├── App │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── Splash.imageset │ │ │ │ ├── splash-2732x2732.png │ │ │ │ ├── splash-2732x2732-1.png │ │ │ │ ├── splash-2732x2732-2.png │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ ├── AppIcon-512@2x.png │ │ │ │ ├── AppIcon-20x20@1x.png │ │ │ │ ├── AppIcon-20x20@2x-1.png │ │ │ │ ├── AppIcon-20x20@2x.png │ │ │ │ ├── AppIcon-20x20@3x.png │ │ │ │ ├── AppIcon-29x29@1x.png │ │ │ │ ├── AppIcon-29x29@2x-1.png │ │ │ │ ├── AppIcon-29x29@2x.png │ │ │ │ ├── AppIcon-29x29@3x.png │ │ │ │ ├── AppIcon-40x40@1x.png │ │ │ │ ├── AppIcon-40x40@2x-1.png │ │ │ │ ├── AppIcon-40x40@2x.png │ │ │ │ ├── AppIcon-40x40@3x.png │ │ │ │ ├── AppIcon-60x60@2x.png │ │ │ │ ├── AppIcon-60x60@3x.png │ │ │ │ ├── AppIcon-76x76@1x.png │ │ │ │ ├── AppIcon-76x76@2x.png │ │ │ │ └── AppIcon-83.5x83.5@2x.png │ │ ├── capacitor.config.json │ │ ├── config.xml │ │ └── Base.lproj │ │ │ └── Main.storyboard │ ├── App.xcodeproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ ├── App.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── Podfile └── .gitignore ├── .monacaignore ├── release-automation ├── package.json ├── tsconfig.json ├── version.ts ├── main.ts ├── bumpPackageVersion.ts └── updateAndroidVersion.ts ├── ionic.config.json ├── capacitor.config.json ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── .circleci └── config.yml ├── .gitignore ├── PRIVACY_POLICY.md ├── .browserslistrc └── tsconfig.json /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/home/home.page.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/about/about.page.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/sandbox/sandbox.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/style/main.scss: -------------------------------------------------------------------------------- 1 | $unit: 8px; 2 | -------------------------------------------------------------------------------- /android/app/src/main/assets/.gitignore: -------------------------------------------------------------------------------- 1 | /public 2 | -------------------------------------------------------------------------------- /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build/* 2 | !/build/.npmkeep 3 | -------------------------------------------------------------------------------- /src/app/shared/modal/modal-frame/modal-frame.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.monaca/local_properties.json: -------------------------------------------------------------------------------- 1 | {"project_id":"61cee60fe7888551730d0d24"} -------------------------------------------------------------------------------- /src/app/home/components/exercise-summary/exercise-summary.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/animations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collapse-vertical'; 2 | -------------------------------------------------------------------------------- /android/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/AtLeastOne.ts: -------------------------------------------------------------------------------- 1 | export type AtLeastOne = [G, ...G[]]; 2 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/icon.png -------------------------------------------------------------------------------- /src/app/shared/ts-utility/PublicMembers.ts: -------------------------------------------------------------------------------- 1 | export type PublicMembers = Omit; 2 | -------------------------------------------------------------------------------- /res/ios/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon.png -------------------------------------------------------------------------------- /resources/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/splash.png -------------------------------------------------------------------------------- /src/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/src/assets/splash.png -------------------------------------------------------------------------------- /res/ios/icon/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-20.png -------------------------------------------------------------------------------- /res/ios/icon/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-40.png -------------------------------------------------------------------------------- /res/ios/icon/icon-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-50.png -------------------------------------------------------------------------------- /res/ios/icon/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-60.png -------------------------------------------------------------------------------- /res/ios/icon/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-72.png -------------------------------------------------------------------------------- /res/ios/icon/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-76.png -------------------------------------------------------------------------------- /res/ios/icon/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon@2x.png -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/style/overrides/html.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | li { 4 | margin-bottom: 2 * $unit; 5 | } 6 | -------------------------------------------------------------------------------- /src/style/overrides/ion-item.scss: -------------------------------------------------------------------------------- 1 | ion-item { 2 | font-size: 14px; 3 | --min-height: 44px; 4 | } 5 | -------------------------------------------------------------------------------- /res/android/icon/hdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/hdpi.png -------------------------------------------------------------------------------- /res/android/icon/ldpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/ldpi.png -------------------------------------------------------------------------------- /res/android/icon/mdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/mdpi.png -------------------------------------------------------------------------------- /res/android/icon/xhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/xhdpi.png -------------------------------------------------------------------------------- /res/ios/icon/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-1024.png -------------------------------------------------------------------------------- /resources/android/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/android/icon.png -------------------------------------------------------------------------------- /src/app/shared/ts-utility/Constructor.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = { new(...args: any[]): G }; 2 | -------------------------------------------------------------------------------- /tsconfig-node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /res/android/icon/xxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/xxhdpi.png -------------------------------------------------------------------------------- /res/android/icon/xxxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/icon/xxxhdpi.png -------------------------------------------------------------------------------- /res/ios/icon/icon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-20@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-40@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-50@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-60@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-60@3x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-72@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-76@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-small.png -------------------------------------------------------------------------------- /resources/ios/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon.png -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/info-panel/info-panel.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/Primitive.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = number | string | boolean | null | undefined; 2 | -------------------------------------------------------------------------------- /src/assets/icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/src/assets/icon/favicon.png -------------------------------------------------------------------------------- /src/style/overrides/ion-list.scss: -------------------------------------------------------------------------------- 1 | ion-list { 2 | &, 3 | &.list-md { 4 | padding: 0; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /res/ios/icon/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-small@2x.png -------------------------------------------------------------------------------- /res/ios/icon/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-small@3x.png -------------------------------------------------------------------------------- /res/ios/screen/Default-667h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-667h.png -------------------------------------------------------------------------------- /res/ios/screen/Default-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-736h.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-20.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-29.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-40.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-50.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-60.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-72.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-76.png -------------------------------------------------------------------------------- /resources/ios/icon/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon@2x.png -------------------------------------------------------------------------------- /src/app/shared/ts-utility/mod.ts: -------------------------------------------------------------------------------- 1 | export function mod(x: number, y: number) { 2 | return ((x % y) + y) % y; 3 | } 4 | -------------------------------------------------------------------------------- /res/android/screen/splash-mdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-mdpi.png -------------------------------------------------------------------------------- /res/ios/icon/icon-83.5@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/icon/icon-83.5@2x~ipad.png -------------------------------------------------------------------------------- /res/ios/screen/Default~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default~iphone.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-1024.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-108@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-108@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-20@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-20@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-24@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-24@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-29@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-29@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-40@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-40@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-44@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-44@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-50@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-60@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-60@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-72@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-76@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-86@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-86@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-98@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-98@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-small.png -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RomanNumeralChordSymbol'; 2 | export * from './Mode'; 3 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TestingUtility'; 2 | export * from './BaseComponentDebugger'; 3 | -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /res/ios/screen/Default@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default@2x~iphone.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-27.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-27.5@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-83.5@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-small@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/icon/icon-small@3x.png -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/consts.ts: -------------------------------------------------------------------------------- 1 | export const MAX_NOTE_NUMBER = 127; 2 | export const MIN_NOTE_NUMBER = 21; 3 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/ArrayItemType.ts: -------------------------------------------------------------------------------- 1 | export type ArrayItemType = GArray extends Array ? U : never; 2 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './publishReplayUntilAndConnect'; 2 | export * from './toObservable'; 3 | -------------------------------------------------------------------------------- /res/electron/icon/icon_electron_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/electron/icon/icon_electron_512.png -------------------------------------------------------------------------------- /resources/android/icon-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/android/icon-background.png -------------------------------------------------------------------------------- /resources/android/icon-foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/android/icon-foreground.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-2436h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-2436h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-667h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-667h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-736h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default~iphone.png -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/exercise-settings.page.scss: -------------------------------------------------------------------------------- 1 | ion-range { 2 | flex: 1; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/keys/Key.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from '../notes/NoteType'; 2 | 3 | export type Key = NoteType; 4 | -------------------------------------------------------------------------------- /src/style/overrides/ion-title.scss: -------------------------------------------------------------------------------- 1 | ion-title { 2 | font-size: 18px; 3 | position: unset; 4 | padding-inline: 20px; 5 | } 6 | -------------------------------------------------------------------------------- /.monaca/project_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "cordova_version": "10.0", 3 | "framework_version": "3.5", 4 | "xcode_version": "11.3" 5 | } -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /res/android/screen/splash-port-hdpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-hdpi.9.png -------------------------------------------------------------------------------- /res/android/screen/splash-port-ldpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-ldpi.9.png -------------------------------------------------------------------------------- /res/android/screen/splash-port-mdpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-mdpi.9.png -------------------------------------------------------------------------------- /res/ios/screen/Default-568h@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-568h@2x~iphone.png -------------------------------------------------------------------------------- /res/ios/screen/Default-Landscape-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-Landscape-736h.png -------------------------------------------------------------------------------- /res/ios/screen/Default-Landscape~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-Landscape~ipad.png -------------------------------------------------------------------------------- /res/ios/screen/Default-Portrait~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-Portrait~ipad.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable/splash.png -------------------------------------------------------------------------------- /res/android/screen/splash-port-xhdpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-xhdpi.9.png -------------------------------------------------------------------------------- /res/android/screen/splash-port-xxhdpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-xxhdpi.9.png -------------------------------------------------------------------------------- /res/android/screen/splash-port-xxxhdpi.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/android/screen/splash-port-xxxhdpi.9.png -------------------------------------------------------------------------------- /res/ios/screen/Default-Landscape@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-Landscape@2x~ipad.png -------------------------------------------------------------------------------- /res/ios/screen/Default-Portrait@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default-Portrait@2x~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default@2x~iphone.png -------------------------------------------------------------------------------- /.monacaignore: -------------------------------------------------------------------------------- 1 | /.monaca/* 2 | !/.monaca/project_info.json 3 | /platforms 4 | .DS_Store 5 | *.swp 6 | .vscode/ 7 | typings/ 8 | node_modules 9 | .git -------------------------------------------------------------------------------- /release-automation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-ear-automation", 3 | "scripts": { 4 | "release": "npx ts-node main.ts" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /res/electron/screen/electron_splash_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/electron/screen/electron_splash_image.png -------------------------------------------------------------------------------- /res/ios/screen/Default@2x~universal~anyany.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/res/ios/screen/Default@2x~universal~anyany.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-1792h~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-1792h~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-2688h~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-2688h~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Portrait~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-568h@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-568h@2x~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape-2436h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape-2436h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape-736h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Portrait@2x~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape@2x~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape@~ipadpro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape@~ipadpro.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait@~ipadpro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Portrait@~ipadpro.png -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/list-select/list-select.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-land-hdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-land-hdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-land-mdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-land-mdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-port-hdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-port-hdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-port-mdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-port-mdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ionic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OpenEar", 3 | "integrations": { 4 | "capacitor": {} 5 | }, 6 | "type": "angular", 7 | "id": "2347bacf" 8 | } 9 | -------------------------------------------------------------------------------- /resources/ios/splash/Default@2x~universal~anyany.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default@2x~universal~anyany.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-land-xhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-land-xhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-land-xxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-land-xxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-port-xhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-port-xhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-port-xxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-port-xxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape-1792h~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape-1792h~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape-2688h~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/resources/ios/splash/Default-Landscape-2688h~iphone.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-land-xxxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-land-xxxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-port-xxxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/drawable-port-xxxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src/app/exercise/utility/music/MusicSymbol.ts: -------------------------------------------------------------------------------- 1 | export enum MusicSymbol { 2 | Flat = '♭', 3 | Sharp = '♯', 4 | Diminished = '°', 5 | HalfDiminished = 'ø', 6 | } 7 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/SolfegeNote.ts: -------------------------------------------------------------------------------- 1 | export type SolfegeNote = 'Do' | 'Ra' | 'Re' | 'Me' | 'Mi' | 'Fa' | 'Fi' | 'Sol' | 'Le' | 'La' | 'Te' | 'Ti'; 2 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png -------------------------------------------------------------------------------- /src/app/shared/ts-utility/isValueTruthy.ts: -------------------------------------------------------------------------------- 1 | export function isValueTruthy(value: T | null | undefined): value is T { 2 | return value !== null && value !== undefined; 3 | } 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /capacitor.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.openear.www", 3 | "appName": "Open Ear", 4 | "webDir": "www", 5 | "bundledWebRuntime": false, 6 | "backgroundColor": "#9955ff" 7 | } 8 | -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/settings/keySelectionSettingsDescriptors.spec.ts: -------------------------------------------------------------------------------- 1 | export const expectedKeySelectionSettingsDescriptors: string[] = [ 2 | 'Key', 3 | 'Change key', 4 | ] 5 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/openear/www/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.openear.www; 2 | 3 | import com.getcapacitor.BridgeActivity; 4 | 5 | public class MainActivity extends BridgeActivity {} 6 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | App/build 2 | App/Pods 3 | App/Podfile.lock 4 | App/App/public 5 | DerivedData 6 | xcuserdata 7 | 8 | # Cordova plugins for Capacitor 9 | capacitor-cordova-ios-plugins 10 | -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amakelov/open-ear/master/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/App/App/capacitor.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.openear.www", 3 | "appName": "Open Ear", 4 | "webDir": "www", 5 | "bundledWebRuntime": false, 6 | "backgroundColor": "#9955ff" 7 | } 8 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/SyncOrAsync.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export type Async = Observable | Promise 4 | 5 | export type SyncOrAsync = G | Async; 6 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/randomFromList.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | export function randomFromList(list: ReadonlyArray): G { 4 | return list[_.random(0, list.length - 1)]; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/toArray.ts: -------------------------------------------------------------------------------- 1 | export type OneOrMany = G | G[]; 2 | 3 | export function toArray(param: OneOrMany): G[] { 4 | return Array.isArray(param) ? param : [param]; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes-page.component.scss: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style-type: "\2713 "; // Light checkmark 3 | 4 | li { 5 | > { 6 | margin-left: 8px; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/timeoutAsPromise.ts: -------------------------------------------------------------------------------- 1 | export function timeoutAsPromise(ms: number = 0): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, ms); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /android/app/src/main/assets/capacitor.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.openear.www", 3 | "appName": "Open Ear", 4 | "webDir": "www", 5 | "bundledWebRuntime": false, 6 | "backgroundColor": "#9955ff" 7 | } 8 | -------------------------------------------------------------------------------- /android/.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/getNoteOctave.ts: -------------------------------------------------------------------------------- 1 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 2 | 3 | export function getNoteOctave(note: Note): number { 4 | return +note.match(/\d+/g)![0]; 5 | } 6 | -------------------------------------------------------------------------------- /release-automation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "sourceMap": true 6 | }, 7 | "exclude": [ 8 | "node_modules" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scaleDegreeToSolfegeNote'; 2 | export * from './SolfegeNote'; 3 | export * from './ScaleDegrees'; 4 | export * from './getResolutionFromScaleDegree'; 5 | -------------------------------------------------------------------------------- /src/app/exercise/utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './music'; 2 | export * from '../../shared/ts-utility'; 3 | export * from './exercise-settings/GlobalExerciseSettings'; 4 | export * from './exercise-settings/ExerciseSettingsData'; 5 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/collapsible/collapsible.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | p { 6 | margin-bottom: 0; 7 | text-decoration: underline; 8 | opacity: 0.7; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/chords/index.ts: -------------------------------------------------------------------------------- 1 | export * from './voiceChordProgressionWithVoiceLeading'; 2 | export * from './Chord/Chord'; 3 | export * from './chordProgressions'; 4 | export { ChordType } from './Chord/ChordType'; 5 | -------------------------------------------------------------------------------- /src/style/overrides/index.scss: -------------------------------------------------------------------------------- 1 | @forward "ion-button"; 2 | @forward "ion-content"; 3 | @forward "ion-card"; 4 | @forward "ion-item"; 5 | @forward "ion-list"; 6 | @forward "ion-title"; 7 | @forward "bdc-walk-popup"; 8 | @forward "html"; 9 | -------------------------------------------------------------------------------- /ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/zone-flags.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prevents Angular change detection from 3 | * running with certain Web Component callbacks 4 | */ 5 | // eslint-disable-next-line no-underscore-dangle 6 | (window as any).__Zone_disable_customElements = true; 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | include ':capacitor-cordova-android-plugins' 3 | project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') 4 | 5 | apply from: 'capacitor.settings.gradle' -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/exerciseAttributes/chordProgressionExercise.spec.ts: -------------------------------------------------------------------------------- 1 | export const expectedVoicingSettingsDescriptors: string[] = [ 2 | 'Voice Leading', 3 | 'Include Bass', 4 | 'Included Positions (top voices)', 5 | ] 6 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/DeepReadonly.ts: -------------------------------------------------------------------------------- 1 | export type DeepReadonly = 2 | G extends Array ? ReadonlyArray> : 3 | G extends Function ? G : 4 | (G extends object ? { readonly [p in keyof G]: DeepReadonly } : G); 5 | -------------------------------------------------------------------------------- /src/style/overrides/ion-button.scss: -------------------------------------------------------------------------------- 1 | ion-button { 2 | --box-shadow: none; 3 | text-transform: none; 4 | flex-shrink: 0; 5 | 6 | &.ios { 7 | height: 2.4em; 8 | } 9 | 10 | &::part(native) { 11 | padding: unset; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/noteTypeToNote.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from './NoteType'; 2 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 3 | 4 | export function noteTypeToNote(noteType: NoteType, octave: number): Note { 5 | return noteType + octave as Note; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/answer-indication/answer-indication.component.html: -------------------------------------------------------------------------------- 1 | 5 | {{answerDisplay}} 6 | 7 | 11 | ? 12 | 13 | -------------------------------------------------------------------------------- /src/app/storage/storage.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | 5 | 6 | @NgModule({ 7 | declarations: [ 8 | ], 9 | imports: [ 10 | CommonModule 11 | ] 12 | }) 13 | export class StorageModule { } 14 | -------------------------------------------------------------------------------- /resources/android/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | localhost 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/info-panel/info-panel.component.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | :host { 4 | background-color: rgba(var(--ion-color-primary-rgb), 0.3); 5 | padding: 2 * $unit; 6 | display: block; 7 | border-radius: $unit; 8 | margin: 2 * $unit 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/storage/migration-scripts/migration-scripts.ts: -------------------------------------------------------------------------------- 1 | import { StorageMigrationScript } from '../storage-migration.service'; 2 | import { migrationScript_1_3_2 } from './storage-migration-1.3.2'; 3 | 4 | export const migrationScripts: StorageMigrationScript[] = [ 5 | migrationScript_1_3_2, 6 | ]; 7 | -------------------------------------------------------------------------------- /src/style/overrides/ion-card.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | ion-card { 4 | &.ios { 5 | margin: 8px 8px; 6 | } 7 | } 8 | 9 | ion-card-title { 10 | &.ios { 11 | font-size: 22px; 12 | } 13 | } 14 | 15 | ion-card-content { 16 | &.ios { 17 | font-size: 14px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/included-answers/included-answers.component.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | :host { 4 | display: block; 5 | padding: 2 * $unit; 6 | } 7 | 8 | ion-button { 9 | &:not(.--included) { 10 | opacity: 0.3; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/base-classes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-component'; 2 | export * from './base-control-value-accessor-component'; 3 | export * from './base-control-value-accessor-service'; 4 | export * from './base-control-value-accessor-with-custom-control'; 5 | export * from './base-destroyable'; 6 | -------------------------------------------------------------------------------- /ios/App/App.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes.testing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ReleaseNotesServiceMock } from './release-notes.service.mock'; 3 | 4 | @NgModule({ 5 | providers: [ 6 | ReleaseNotesServiceMock.providers, 7 | ], 8 | }) 9 | export class ReleaseNotesTestingModule { 10 | } 11 | -------------------------------------------------------------------------------- /src/app/storage/storage.testing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { StorageMigrationServiceMock } from './storage-migration.service.mock'; 3 | 4 | @NgModule({ 5 | providers: [ 6 | ...StorageMigrationServiceMock.providers, 7 | ], 8 | }) 9 | export class StorageTestingModule { 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/getNoteType.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from './NoteType'; 2 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 3 | 4 | export function getNoteType(note: Note): NoteType { 5 | return note.split('').filter(c => ['A', 'B', 'C', 'D', 'E', 'F', 'G', '#', 'b'].includes(c)).join('') as NoteType; 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/App/App/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/intervals/getInterval.ts: -------------------------------------------------------------------------------- 1 | import { NoteNumberOrName } from '../notes/NoteNumberOrName'; 2 | import { toNoteNumber } from '../notes/toNoteName'; 3 | 4 | export function getInterval(note1: NoteNumberOrName, note2: NoteNumberOrName): number { 5 | return Math.abs(toNoteNumber(note1) - toNoteNumber(note2)); 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Open Ear 4 | Open Ear 5 | com.openear.www 6 | com.openear.www 7 | 8 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/content-padding.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, HostBinding, Input} from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: 'ion-content[padding]' 5 | }) 6 | export class ContentPaddingDirective { 7 | @HostBinding('class.--padding') 8 | @Input('padding') 9 | isWithPadding: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/settings/withSettings.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | import { SettingsParams } from './SettingsParams'; 3 | 4 | export function withSettings(p: SettingsParams): ({}) => SettingsParams { 5 | return function({}) { 6 | return p; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/collapsible/collapsible.component.html: -------------------------------------------------------------------------------- 1 |

4 | {{isCollapsed ? 'Click here to learn more' : 'Hide'}} 5 |

6 | 7 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-help/exercise-explanation/exercise-explanation.page.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | .exercise-explanation { 4 | &__content { 5 | flex-grow: 1; 6 | margin-bottom: 2 * $unit; 7 | } 8 | } 9 | 10 | ion-content { 11 | &::part(scroll) { 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toSteadyPart'; 2 | export * from './notes/toNoteName'; 3 | export * from './NotesRange'; 4 | export * from './keys/Key'; 5 | export * from './intervals/Interval'; 6 | export * from './intervals/getInterval'; 7 | export * from './scale-degrees' 8 | export * from './harmony'; 9 | export * from './MusicSymbol'; 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/exerciseAttributes/defaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | import { SettingsParams } from '../settings/SettingsParams'; 3 | 4 | export function defaultSettings(defaultSettings: Settings): Pick, 'defaultSettings'> { 5 | return { 6 | defaultSettings, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/exercise/utility/exercise-settings/ExerciseSettingsData.ts: -------------------------------------------------------------------------------- 1 | import { GlobalExerciseSettings } from './GlobalExerciseSettings'; 2 | import { Exercise } from '../../Exercise'; 3 | 4 | export interface ExerciseSettingsData { 5 | globalSettings: GlobalExerciseSettings, 6 | exerciseSettings: { [key: string]: Exercise.SettingValueType }, 7 | wasExplanationDisplayed?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/home/components/exercise-summary/exercise-summary.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | {{exercise.name}} 7 | 8 | 9 | 10 | {{exercise.summary}} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/NotesWithChords/notes-with-chords-explanation/notes-with-chords-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-notes-with-chords-explanation', 5 | templateUrl: './notes-with-chords-explanation.component.html', 6 | }) 7 | export class NotesWithChordsExplanationComponent { 8 | constructor() { } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/romanNumeralToChordInC.ts: -------------------------------------------------------------------------------- 1 | import { RomanNumeralChordSymbol } from './RomanNumeralChordSymbol'; 2 | import { Chord } from '../chords'; 3 | import { RomanNumeralChord } from './RomanNumeralChord'; 4 | 5 | export function romanNumeralToChordInC(romanNumeralSymbol: RomanNumeralChordSymbol): Chord { 6 | return new RomanNumeralChord(romanNumeralSymbol).getChord('C'); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [], 7 | "strictNullChecks": true, 8 | }, 9 | "files": [ 10 | "src/main.ts", 11 | "src/polyfills.ts" 12 | ], 13 | "include": [ 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/field-info/field-info.component.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | :host { 4 | color: var(--ion-color-medium); 5 | position: relative; 6 | } 7 | 8 | button { 9 | background-color: unset; 10 | color: inherit; 11 | padding: 0; 12 | } 13 | 14 | .field-info__content { 15 | padding: 2 * $unit; 16 | font-size: 14px; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/settings/SettingsParams.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | import { StaticOrGetter } from '../../../../shared/ts-utility'; 3 | 4 | export type SettingsParams = { 5 | readonly settingsDescriptors?: StaticOrGetter[], [GSettings]>; 6 | readonly defaultSettings: GSettings, 7 | } 8 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/toObservable.ts: -------------------------------------------------------------------------------- 1 | import { from, Observable, of } from 'rxjs'; 2 | import { SyncOrAsync } from './SyncOrAsync'; 3 | 4 | export function toObservable(input: SyncOrAsync): Observable { 5 | if (input instanceof Observable) { 6 | return input; 7 | } 8 | 9 | if (input instanceof Promise) { 10 | return from(input); 11 | } 12 | 13 | return of(input); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/scaleDegreeToNoteType.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ScaleDegree, 3 | scaleDegreeToChromaticDegree, 4 | } from './ScaleDegrees'; 5 | import { Key } from '../keys/Key'; 6 | import { transpose } from '../transpose'; 7 | 8 | export function scaleDegreeToNoteType(scaleDegree: ScaleDegree, key: Key) { 9 | return transpose(key, scaleDegreeToChromaticDegree[scaleDegree] - 1); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/info-panel/info-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-info-panel', 5 | templateUrl: './info-panel.component.html', 6 | styleUrls: ['./info-panel.component.scss'], 7 | }) 8 | export class InfoPanelComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() {} 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/CommonChordProgressionExercise/common-chord-progressions-explanation/common-chord-progressions-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-common-chord-progressions-explanation', 5 | templateUrl: './common-chord-progressions-explanation.component.html', 6 | }) 7 | export class CommonChordProgressionsExplanationComponent { 8 | } 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | import { AppModule } from './app/app.module'; 4 | import { environment } from './environments/environment'; 5 | 6 | if (environment.production) { 7 | enableProdMode(); 8 | } 9 | 10 | platformBrowserDynamic().bootstrapModule(AppModule) 11 | .catch(err => console.log(err)); 12 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/getMusicTextDisplay.ts: -------------------------------------------------------------------------------- 1 | import { MusicSymbol } from './MusicSymbol'; 2 | 3 | export function toMusicalTextDisplay(text: string): string { 4 | return text 5 | // @ts-ignore (For some reason this native methods is not updated in typescript types) 6 | .replaceAll('#', MusicSymbol.Sharp) 7 | .replaceAll('b', MusicSymbol.Flat) 8 | .replaceAll('dim', MusicSymbol.Diminished); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/ng-utilities/pure-function-pipe/pure-function.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Pipe, PipeTransform, 3 | } from '@angular/core'; 4 | 5 | @Pipe({ 6 | name: 'pureFunction', 7 | pure: true, 8 | }) 9 | export class PureFunctionPipe implements PipeTransform { 10 | transform>(pureFunction: (...args: U) => T, ...functionArgs: U): T { 11 | return pureFunction(...functionArgs); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/style/overrides/ion-content.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | ion-content { 4 | &.--padding { 5 | &::part(scroll) { 6 | padding: { 7 | right: 2 * $unit; 8 | left: 2 * $unit; 9 | }; 10 | } 11 | } 12 | 13 | &::part(scroll) { 14 | // Fixes bug in ionic where padding bottom is too big 15 | padding-bottom: calc(var(--padding-bottom) + var(--keyboard-offset)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/jasmine/custom-matchers/spy-matchers/spy-matchers.ts: -------------------------------------------------------------------------------- 1 | import CustomMatcherFactories = jasmine.CustomMatcherFactories; 2 | import { toHaveOnlyBeenCalledWith } from './to-have-only-been-called-with'; 3 | import { toHaveBeenLastCalledWith } from './to-have-been-last-called-with'; 4 | 5 | export const spyMatchers: CustomMatcherFactories = { 6 | toHaveOnlyBeenCalledWith, 7 | toHaveBeenLastCalledWith, 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/shared/ng-utilities/console-log-component/console-log.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, } from '@angular/core'; 2 | 3 | /** 4 | * For debugging purposes only 5 | * */ 6 | @Component({ 7 | selector: 'app-console-log', 8 | template: '', 9 | }) 10 | export class ConsoleLogComponent { 11 | @Input('message') 12 | set message(msg: any) { 13 | console.log(msg); 14 | } 15 | 16 | constructor() { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-help/exercise-explanation/exercise-explanation.page.html: -------------------------------------------------------------------------------- 1 | 5 |
8 | 9 |
10 | 11 | Got It 12 |
13 | -------------------------------------------------------------------------------- /src/app/home/home-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { HomePage } from './home.page'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: '', 8 | component: HomePage 9 | } 10 | ]; 11 | 12 | @NgModule({ 13 | imports: [RouterModule.forChild(routes)], 14 | exports: [RouterModule], 15 | }) 16 | export class HomePageRoutingModule {} 17 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/tapLogValue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | OperatorFunction, 4 | } from 'rxjs'; 5 | import { tap } from 'rxjs/operators'; 6 | 7 | export function tapLogValue(label?: string): OperatorFunction { 8 | return function(source$: Observable): Observable { 9 | return source$ 10 | .pipe( 11 | tap((v) => label ? console.log(label, v) : console.log(v)), 12 | ); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/sandbox/sandbox.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sandbox 5 | 6 | 7 | 8 | 9 | Play Simple part 10 | Play 11 |

12 | {{currentlyPlaying}} 13 |

14 |
15 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/toPromise.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | firstValueFrom, 4 | } from 'rxjs'; 5 | import { SyncOrAsync } from './SyncOrAsync'; 6 | 7 | export function toPromise(param: SyncOrAsync): Promise { 8 | if (param instanceof Observable) { 9 | return firstValueFrom(param); 10 | } 11 | 12 | if (param instanceof Promise) { 13 | return param; 14 | } 15 | 16 | return Promise.resolve(param); 17 | } 18 | -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | These are Cordova resources. You can replace icon.png and splash.png and run 2 | `ionic cordova resources` to generate custom icons and splash screens for your 3 | app. See `ionic cordova resources --help` for details. 4 | 5 | Cordova reference documentation: 6 | 7 | - Icons: https://cordova.apache.org/docs/en/latest/config_ref/images.html 8 | - Splash Screens: https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-splashscreen/ 9 | -------------------------------------------------------------------------------- /src/app/about/about.page.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {AppVersion} from "@ionic-native/app-version/ngx"; 3 | import { VersionService } from '../version.service'; 4 | 5 | @Component({ 6 | selector: 'app-about', 7 | templateUrl: './about.page.html', 8 | styleUrls: ['./about.page.scss'], 9 | }) 10 | export class AboutPage { 11 | constructor( 12 | public readonly versionService: VersionService, 13 | ) { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/state/adaptive-exercise.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Exercise } from '../../Exercise'; 3 | import { AdaptiveExercise } from './adaptive-exercise'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class AdaptiveExerciseService { 9 | createAdaptiveExercise(exercise: Exercise.Exercise): AdaptiveExercise { 10 | return new AdaptiveExercise(exercise); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/TriadInversionExercise/triad-inversion-explanation/triad-inversion-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-triad-inversion-explanation', 5 | templateUrl: './triad-inversion-explanation.component.html', 6 | }) 7 | export class TriadInversionExplanationComponent implements OnInit { 8 | 9 | constructor() { } 10 | 11 | ngOnInit() {} 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/toNoteType.spec.ts: -------------------------------------------------------------------------------- 1 | import { getNoteType } from './getNoteType'; 2 | 3 | describe('toNoteType', function () { 4 | it('C4 is a C note', () => { 5 | expect(getNoteType('C4')).toEqual('C'); 6 | }); 7 | 8 | it('Bb3 is a Bb note', () => { 9 | expect(getNoteType('Bb3')).toEqual('Bb'); 10 | }); 11 | 12 | it('F#5 is an F# note', () => { 13 | expect(getNoteType('F#5')).toEqual('F#'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/shared/animations/fade.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnimationTriggerMetadata, 3 | style, 4 | trigger, 5 | } from '@angular/animations'; 6 | import {enterLeaveAnimationFactory} from "./enter-leave-animation-factory"; 7 | 8 | export const fade: AnimationTriggerMetadata = trigger('fade', enterLeaveAnimationFactory({ 9 | steps: [ 10 | style({ 11 | opacity: 0, 12 | }), 13 | style({ 14 | opacity: 1, 15 | }), 16 | ], 17 | })); 18 | -------------------------------------------------------------------------------- /src/app/services/player.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PlayerService } from './player.service'; 4 | 5 | xdescribe('PlayerService', () => { 6 | let service: PlayerService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PlayerService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/shared/ng-utilities/pure-function-pipe/pure-function-pipe.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { PureFunctionPipe } from './pure-function.pipe'; 4 | 5 | @NgModule({ 6 | declarations: [ 7 | PureFunctionPipe, 8 | ], 9 | imports: [ 10 | CommonModule 11 | ], 12 | exports: [ 13 | PureFunctionPipe, 14 | ] 15 | }) 16 | export class PureFunctionPipeModule { } 17 | -------------------------------------------------------------------------------- /src/app/shared/reactive-forms/index.ts: -------------------------------------------------------------------------------- 1 | export { IAbstractControl } from './abstractControl'; 2 | export { FormControl } from './formControl'; 3 | export { FormGroup } from './formGroup'; 4 | export * from './formArray'; 5 | export { 6 | ValidatorFn, 7 | AsyncValidatorFn, 8 | NgValidatorsErrors, 9 | TFlatControlsOf, 10 | TControlsOf, 11 | } from './types'; 12 | export { ControlValueAccessor } from './ControlValueAccessor'; 13 | export * from './types'; 14 | -------------------------------------------------------------------------------- /src/app/exercise/exercise-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { ExercisePage } from './exercise.page/exercise.page'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: '', 8 | component: ExercisePage, 9 | } 10 | ]; 11 | 12 | @NgModule({ 13 | imports: [RouterModule.forChild(routes)], 14 | exports: [RouterModule], 15 | }) 16 | export class ExerciseRoutingModule {} 17 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-help/exercise-explanation/exercise-explanation.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-exercise-explanation', 5 | templateUrl: './exercise-explanation.page.html', 6 | styleUrls: ['./exercise-explanation.page.scss'], 7 | }) 8 | export class ExerciseExplanationPage { 9 | @Input() 10 | content: string; 11 | 12 | @Input() 13 | exerciseName: string; 14 | } 15 | -------------------------------------------------------------------------------- /release-automation/version.ts: -------------------------------------------------------------------------------- 1 | export type Version = { 2 | major: number, 3 | minor: number, 4 | patch: number, 5 | } 6 | 7 | export function parseVersion(version: string): Version { 8 | const [major, minor, patch] = version.split('.').map(v => +v); 9 | return { 10 | major, 11 | minor, 12 | patch, 13 | } 14 | } 15 | 16 | export function formatVersion(version: Version): string { 17 | return `${version.major}.${version.minor}.${version.patch}`; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/services/drone-player.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { DronePlayerService } from './drone-player.service'; 4 | 5 | describe('DroneService', () => { 6 | let service: DronePlayerService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(DronePlayerService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/shared/ng-utilities/console-log-component/console-log-component.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ConsoleLogComponent } from './console-log.component'; 4 | 5 | @NgModule({ 6 | declarations: [ 7 | ConsoleLogComponent, 8 | ], 9 | imports: [ 10 | CommonModule 11 | ], 12 | exports: [ 13 | ConsoleLogComponent, 14 | ] 15 | }) 16 | export class ConsoleLogComponentModule { } 17 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/StaticOrGetter.ts: -------------------------------------------------------------------------------- 1 | export type StaticOrGetter = GValue | ((...param: GParam) => GValue); 2 | 3 | export type ResolvedValueOf = G extends StaticOrGetter ? U : never; 4 | 5 | export function toGetter(staticOrGetter: StaticOrGetter): ((...param: GParam) => GValue) { 6 | return (...param: GParam) => staticOrGetter instanceof Function ? staticOrGetter(...param) : staticOrGetter; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/shareReplayUntil.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | MonoTypeOperatorFunction, 4 | } from 'rxjs'; 5 | import { 6 | takeUntil, 7 | shareReplay, 8 | } from 'rxjs/operators'; 9 | 10 | export function shareReplayUntil(notifier: Observable): MonoTypeOperatorFunction { 11 | return (source$: Observable) => { 12 | return source$ 13 | .pipe( 14 | takeUntil(notifier), 15 | shareReplay(1), 16 | ); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/view-message/view-message-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | 4 | import { ViewMessagePage } from './view-message.page'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: ViewMessagePage 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class ViewMessagePageRoutingModule {} 18 | -------------------------------------------------------------------------------- /android/variables.gradle: -------------------------------------------------------------------------------- 1 | ext { 2 | minSdkVersion = 21 3 | compileSdkVersion = 30 4 | targetSdkVersion = 30 5 | androidxActivityVersion = '1.2.0' 6 | androidxAppCompatVersion = '1.2.0' 7 | androidxCoordinatorLayoutVersion = '1.1.0' 8 | androidxCoreVersion = '1.3.2' 9 | androidxFragmentVersion = '1.3.0' 10 | junitVersion = '4.13.1' 11 | androidxJunitVersion = '1.1.2' 12 | androidxEspressoCoreVersion = '3.3.0' 13 | cordovaAndroidVersion = '7.0.0' 14 | } -------------------------------------------------------------------------------- /src/app/exercise/utility/exercise-settings/GlobalExerciseSettings.ts: -------------------------------------------------------------------------------- 1 | export interface GlobalExerciseSettings { 2 | /** 3 | * If received number it will play the cadence every n exercises 4 | * */ 5 | playCadence: true | false | 'ONLY_ON_REPEAT' /*| 'EVERY_NEW_KEY' | number*/; // TODO(OE-12, OE-13) 6 | adaptive: boolean; 7 | revealAnswerAfterFirstMistake: boolean; 8 | bpm: number; 9 | moveToNextQuestionAutomatically: boolean; 10 | answerQuestionAutomatically: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './randomFromList'; 2 | export * from './timeoutAsPromise'; 3 | export * from './toArray'; 4 | export * from './isValueTruthy'; 5 | export * from './Primitive'; 6 | export * from './ArrayItemType'; 7 | export * from './rxjs'; 8 | export * from './base-classes'; 9 | export * from './StaticOrGetter'; 10 | export * from './LogReturnValue'; 11 | export * from './Constructor'; 12 | export * from './AtLeastOne'; 13 | export * from './DeepReadonly'; 14 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes-page.component.html: -------------------------------------------------------------------------------- 1 | 5 |

6 | Version: {{versionService.version$ | async}} 7 |

8 |
    9 |
  • 13 |
14 | Nice! Let's go 15 |
16 | -------------------------------------------------------------------------------- /android/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /src/app/release-notes/version-comparator.ts: -------------------------------------------------------------------------------- 1 | import { toNumber } from 'lodash'; 2 | 3 | export function versionComparator(version1: string, version2: string): number { // negative if version1 < version2 4 | const split1 = version1.split('.').map(v => toNumber(v)); 5 | const split2 = version2.split('.').map(v => toNumber(v)); 6 | 7 | for (let i = 0; i < split1.length; i++) { 8 | if (split1[i] - split2[i] != 0) { 9 | return split1[i] - split2[i]; 10 | } 11 | } 12 | 13 | return 0; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/mod.spec.ts: -------------------------------------------------------------------------------- 1 | import { mod } from './mod'; 2 | import { testPureFunction } from '../testing-utility/testPureFunction'; 3 | 4 | describe('mod', () => { 5 | testPureFunction(mod, [ 6 | { 7 | args: [5, 12], 8 | returnValue: 5, 9 | }, 10 | { 11 | args: [14, 12], 12 | returnValue: 2, 13 | }, 14 | { 15 | args: [-4, 12], 16 | returnValue: 8, 17 | }, 18 | { 19 | args: [-16, 12], 20 | returnValue: 8, 21 | } 22 | ]) 23 | }) 24 | -------------------------------------------------------------------------------- /android/app/src/main/assets/capacitor.plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pkg": "@capacitor/app", 4 | "classpath": "com.capacitorjs.plugins.app.AppPlugin" 5 | }, 6 | { 7 | "pkg": "@capacitor/haptics", 8 | "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" 9 | }, 10 | { 11 | "pkg": "@capacitor/keyboard", 12 | "classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin" 13 | }, 14 | { 15 | "pkg": "@capacitor/status-bar", 16 | "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/getRelativeKeyTonic.spec.ts: -------------------------------------------------------------------------------- 1 | import { getRelativeKeyTonic } from './getRelativeKeyTonic'; 2 | import { testPureFunction } from '../../../../shared/testing-utility/testPureFunction'; 3 | import { Mode } from './Mode'; 4 | 5 | describe(getRelativeKeyTonic.name, () => { 6 | testPureFunction(getRelativeKeyTonic, [ 7 | { 8 | args: ['D', Mode.Major], 9 | returnValue: 'B', 10 | }, 11 | { 12 | args: ['D', Mode.Minor], 13 | returnValue: 'F', 14 | }, 15 | ]) 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/intervals/Interval.ts: -------------------------------------------------------------------------------- 1 | export enum Interval { 2 | Unison = 0, 3 | MinorSecond = 1, 4 | MajorSecond = 2, 5 | MinorThird = 3, 6 | MajorThird = 4, 7 | PerfectFourth = 5, 8 | AugmentedForth = 6, 9 | DiminishedFifth = 6, 10 | PerfectFifth = 7, 11 | AugmentedFifth = 8, 12 | MinorSixth = 8, 13 | MajorSixth = 9, 14 | DiminishedSeventh = 9, 15 | MinorSeventh = 10, 16 | MajorSeventh = 11, 17 | Octave = 12, 18 | MinorNinth = 13, 19 | MajorNinth = 14, 20 | AugmentedNinth = 15, 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package com.getcapacitor.myapp; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import org.junit.Test; 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * @see Testing documentation 11 | */ 12 | public class ExampleUnitTest { 13 | 14 | @Test 15 | public void addition_isCorrect() throws Exception { 16 | assertEquals(4, 2 + 2); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/field-info/field-info.component.html: -------------------------------------------------------------------------------- 1 | 9 | 13 | 14 |
18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/collapsible/collapsible.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {collapseVertical} from "../../../animations"; 3 | import { fade } from '../../../animations/fade'; 4 | 5 | @Component({ 6 | selector: 'app-collapsible', 7 | templateUrl: './collapsible.component.html', 8 | styleUrls: ['./collapsible.component.scss'], 9 | animations: [ 10 | collapseVertical, 11 | fade, 12 | ] 13 | }) 14 | export class CollapsibleComponent { 15 | isCollapsed = true; 16 | } 17 | -------------------------------------------------------------------------------- /src/app/shared/ionic-testing/ionic-testing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AlertControllerMock } from './services/alert-controller.mock'; 3 | import { ToastControllerMock } from './services/toaster-controller.mock'; 4 | import { ModalControllerMock } from './services/modal-controller.mock'; 5 | 6 | @NgModule({ 7 | providers: [ 8 | ...AlertControllerMock.providers, 9 | ...ToastControllerMock.providers, 10 | ...ModalControllerMock.providers, 11 | ] 12 | }) 13 | export class IonicTestingModule { } 14 | -------------------------------------------------------------------------------- /release-automation/main.ts: -------------------------------------------------------------------------------- 1 | import { bumpPackageVersion } from './bumpPackageVersion'; 2 | import { formatVersion } from './version'; 3 | import { updateAndroidVersion } from './updateAndroidVersion'; 4 | 5 | const version = bumpPackageVersion('patch'); 6 | updateAndroidVersion(version); 7 | 8 | /** 9 | * TODO: 10 | * - Commit 11 | * - Create and Tag 12 | * - Build bundles 13 | * - Create release on github with bundles 14 | * - Create testing release on google play 15 | * */ 16 | 17 | console.log('Done Release ', formatVersion(version)); 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | # TODO: consider adding caching for dependencies (See examples here: https://dev.to/obinnaogbonnajoseph/circle-ci-test-configuration-for-angular-projects-1o2p) 4 | jobs: 5 | ci: 6 | docker: 7 | - image: circleci/node:16-browsers 8 | steps: 9 | - checkout 10 | - run: 11 | name: Install local dependencies 12 | command: yarn 13 | - run: 14 | name: Testing 15 | command: yarn ci 16 | 17 | workflows: 18 | main: 19 | jobs: 20 | - ci 21 | -------------------------------------------------------------------------------- /ios/App/App/Assets.xcassets/Splash.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "splash-2732x2732-2.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "splash-2732x2732-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "splash-2732x2732.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /src/app/shared/testing-utility/jasmine/custom-matchers/utility.ts: -------------------------------------------------------------------------------- 1 | import CustomMatcherResult = jasmine.CustomMatcherResult; 2 | import MatchersUtil = jasmine.MatchersUtil; 3 | 4 | export function compareEquality(util: MatchersUtil, expected: G, actual: G, failureMessage: string = ''): CustomMatcherResult { 5 | const result: CustomMatcherResult = jasmine.matchers.toEqual(util).compare(actual, expected); 6 | if (!result.pass && failureMessage) { 7 | result.message = failureMessage + '\n' + result.message; 8 | } 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/TriadInversionExercise/triadInversionExercise.spec.ts: -------------------------------------------------------------------------------- 1 | import { triadInversionExercise } from './triadInversionExercise'; 2 | import { testExercise } from '../testing-utility/test-exercise.spec'; 3 | 4 | describe(triadInversionExercise.name, () => { 5 | const context = testExercise({ 6 | getExercise: triadInversionExercise, 7 | settingDescriptorList: [ 8 | 'Included Inversions', 9 | 'Arpeggiate Speed', 10 | 'Play Root After Correct Answer', 11 | 'Arpeggio Direction', 12 | ], 13 | }); 14 | }) 15 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/getNoteOctave.spec.ts: -------------------------------------------------------------------------------- 1 | import { getNoteOctave } from './getNoteOctave'; 2 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 3 | import * as _ from 'lodash'; 4 | 5 | describe('getNoteOctave', () => { 6 | const noteToExpectedOctave: {[note in Note]?: number} = { 7 | 'C1': 1, 8 | 'Bb3': 3, 9 | 'G9': 9, 10 | } 11 | 12 | _.forEach(noteToExpectedOctave, (expected: number, note: Note) => { 13 | it(note, () => { 14 | expect(getNoteOctave(note)).toEqual(expected); 15 | }) 16 | }) 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/shared/modal/modal-frame/modal-frame.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/base-classes/base-destroyable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | OnDestroy, 4 | } from '@angular/core'; 5 | import { Subject } from 'rxjs'; 6 | 7 | @Injectable() 8 | export class BaseDestroyable implements OnDestroy { 9 | protected _destroy$ = new Subject(); 10 | 11 | ngOnDestroy(): void { 12 | this._destroy$.next(); 13 | this._destroy$.complete(); 14 | this._onDestroy(); 15 | } 16 | 17 | /** 18 | * Override this when extending if needs to 19 | * */ 20 | protected _onDestroy(): void { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/field-info/field-info.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input, 4 | } from '@angular/core'; 5 | 6 | @Component({ 7 | selector: 'app-field-info', 8 | templateUrl: './field-info.component.html', 9 | styleUrls: ['./field-info.component.scss'], 10 | }) 11 | export class FieldInfoComponent { 12 | static instanceIndex: number = 0; 13 | readonly instanceIndex = FieldInfoComponent.instanceIndex++; 14 | isOpened: boolean = false; 15 | 16 | @Input() 17 | message: string = ''; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/noteTypeToScaleDegree.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from '../notes/NoteType'; 2 | import { Key } from '../keys/Key'; 3 | import { 4 | ScaleDegree, 5 | chromaticDegreeToScaleDegree, 6 | } from './ScaleDegrees'; 7 | import { toNoteTypeNumber } from '../notes/toNoteTypeNumber'; 8 | 9 | export function noteTypeToScaleDegree(noteType: NoteType, key: Key): ScaleDegree { 10 | const chromaticDegree: number = Math.abs(toNoteTypeNumber(noteType) - toNoteTypeNumber(key)) + 1; 11 | return chromaticDegreeToScaleDegree[chromaticDegree]; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/version.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { VersionService } from './version.service'; 2 | import { Provider } from '@angular/core'; 3 | import { ReplaySubject } from 'rxjs'; 4 | 5 | export class VersionServiceMock implements Pick { 6 | readonly version$ = new ReplaySubject(1); 7 | 8 | set version(v: string) { 9 | this.version$.next(v); 10 | } 11 | 12 | static providers: Provider[] = [ 13 | VersionServiceMock, 14 | { 15 | provide: VersionService, 16 | useExisting: VersionServiceMock, 17 | } 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /src/app/exercise/ExerciseTest.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from './Exercise'; 2 | 3 | export namespace ExerciseTest { 4 | export function answerListContaining(answerList: ReadonlyArray): jasmine.AsymmetricMatcher> { 5 | return { 6 | asymmetricMatch(other: Exercise.AnswerList, customTesters: ReadonlyArray): boolean { 7 | return jasmine.arrayWithExactContents(answerList).asymmetricMatch(Exercise.flatAnswerList(other), customTesters); 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/scaleDegreeToNoteType.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../../../shared/testing-utility/testPureFunction'; 2 | import { scaleDegreeToNoteType } from './scaleDegreeToNoteType'; 3 | 4 | describe('scaleDegreeToNoteType', () => { 5 | testPureFunction(scaleDegreeToNoteType, [ 6 | { 7 | args: ['1', 'C'], 8 | returnValue: 'C', 9 | }, 10 | { 11 | args: ['4', 'Bb'], 12 | returnValue: 'D#', 13 | }, 14 | { 15 | args: ['b2', 'F#'], 16 | returnValue: 'G', 17 | }, 18 | ]) 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/shared/reactive-forms/ControlValueAccessor.ts: -------------------------------------------------------------------------------- 1 | import { ControlValueAccessor as NgControlValueAccessor } from '@angular/forms'; 2 | 3 | export abstract class ControlValueAccessor implements NgControlValueAccessor { 4 | abstract writeValue(value: GValue): void; 5 | 6 | onChange ? = (value: GValue | null): void => {}; 7 | onTouched ? = (): void => {}; 8 | 9 | registerOnChange(fn: (value: GValue | null) => void): void { 10 | this.onChange = fn; 11 | } 12 | 13 | registerOnTouched(fn: () => void): void { 14 | this.onTouched = fn; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /release-automation/bumpPackageVersion.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { 3 | parseVersion, 4 | Version, 5 | formatVersion, 6 | } from './version'; 7 | 8 | export function bumpPackageVersion(bump: 'major' | 'minor' | 'patch', path: string = '../package.json'): Version { 9 | const packageJson = JSON.parse(fs.readFileSync(path, 'utf8')); 10 | const version: Version = parseVersion(packageJson.version); 11 | version[bump]++; 12 | packageJson.version = formatVersion(version); 13 | fs.writeFileSync(path, JSON.stringify(packageJson, null, 2)); 14 | return version; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/storage/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Storage } from '@ionic/storage-angular'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class StorageService { 8 | private readonly storagePromise: Promise = this._storage.create(); 9 | 10 | constructor(private _storage: Storage) { } 11 | 12 | async get(key: string): Promise { 13 | return (await this.storagePromise).get(key); 14 | } 15 | 16 | async set(key: string, value: any): Promise { 17 | return (await this.storagePromise).set(key, value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/NoteType.ts: -------------------------------------------------------------------------------- 1 | export type NoteType = 'C' | 'F' | 'A#' | 'Bb' | 'D#' | 'Eb' | 'G#' | 'Ab' | 'C#' | 'Db' | 'F#' | 'Gb' | 'B' | 'E' | 'A' | 'D' | 'G'; 2 | const noteTypeMap: {[noteType in NoteType]: true} = { 3 | 'A#': true, 4 | 'C#': true, 5 | 'D#': true, 6 | 'F#': true, 7 | 'G#': true, 8 | A: true, 9 | B: true, 10 | C: true, 11 | D: true, 12 | E: true, 13 | F: true, 14 | G: true, 15 | Ab: true, 16 | Bb: true, 17 | Db: true, 18 | Eb: true, 19 | Gb: true, 20 | } 21 | export const ALL_NOTE_TYPES: NoteType[] = Object.keys(noteTypeMap) as NoteType[]; 22 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { VersionService } from '../version.service'; 3 | import { ReleaseNotesService } from './release-notes.service'; 4 | 5 | @Component({ 6 | selector: 'app-release-notes', 7 | templateUrl: './release-notes-page.component.html', 8 | styleUrls: ['./release-notes-page.component.scss'], 9 | }) 10 | export class ReleaseNotesPage { 11 | constructor( 12 | public readonly versionService: VersionService, 13 | public readonly releaseNotesService: ReleaseNotesService, 14 | ) { 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/testPureFunction.ts: -------------------------------------------------------------------------------- 1 | import Expected = jasmine.Expected; 2 | 3 | export function testPureFunction any>( 4 | func: GFunc, 5 | cases: { 6 | args: Parameters, 7 | returnValue: Expected>, 8 | force?: boolean, 9 | }[]): void { 10 | cases.forEach(testCase => { 11 | (testCase.force ? fit : it)(`${func.name}(${testCase.args.map(arg => JSON.stringify(arg)).join(', ')}) = ${JSON.stringify(testCase.returnValue)}`, () => { 12 | expect(func(...testCase.args)).toEqual(testCase.returnValue); 13 | }); 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/getRelativeKeyTonic.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from '../notes/NoteType'; 2 | import { Mode } from './Mode'; 3 | import { 4 | toNoteTypeName, 5 | toNoteTypeNumber, 6 | } from '../notes/toNoteTypeNumber'; 7 | import { mod } from '../../../../shared/ts-utility/mod'; 8 | import { Interval } from '../intervals/Interval'; 9 | 10 | export function getRelativeKeyTonic(tonic: NoteType, mode: Mode): NoteType { 11 | const differenceToRelativeTonic = mode === Mode.Major ? -3 : 3; 12 | return toNoteTypeName(mod(toNoteTypeNumber(tonic) + differenceToRelativeTonic, Interval.Octave)) 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/shared/modal/modal.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ModalFrameComponent } from './modal-frame/modal-frame.component'; 4 | import { IonicModule } from '@ionic/angular'; 5 | import { SharedComponentsModule } from '../components/shared-components/shared-components.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | ModalFrameComponent, 10 | ], 11 | imports: [ 12 | CommonModule, 13 | IonicModule, 14 | SharedComponentsModule, 15 | ], 16 | exports: [ 17 | ModalFrameComponent, 18 | ] 19 | }) 20 | export class ModalModule { } 21 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/list-select/list-select.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{label}} 4 | 5 | 6 | 7 | {{answer.label}} 8 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/NotesWithChords/notesWithChordsExercise.spec.ts: -------------------------------------------------------------------------------- 1 | import { notesWithChordsExercise } from './notesWithChordsExercise'; 2 | import { testExercise } from '../testing-utility/test-exercise.spec'; 3 | import { expectedKeySelectionSettingsDescriptors } from '../utility/settings/keySelectionSettingsDescriptors.spec'; 4 | 5 | describe(notesWithChordsExercise.name, () => { 6 | testExercise({ 7 | getExercise: notesWithChordsExercise, 8 | settingDescriptorList: [ 9 | 'Included Options', 10 | ...expectedKeySelectionSettingsDescriptors, 11 | 'Drone', 12 | 'Voice Mode', 13 | ], 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/getMusicTextDisplay.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../../shared/testing-utility/testPureFunction'; 2 | import { toMusicalTextDisplay } from './getMusicTextDisplay'; 3 | 4 | describe('toMusicalTextDisplay', () => { 5 | testPureFunction(toMusicalTextDisplay, [ 6 | { 7 | args: ['b3'], 8 | returnValue: '♭3' 9 | }, 10 | { 11 | args: ['viidim'], 12 | returnValue: 'vii°' 13 | }, 14 | { 15 | args: ['#IV'], 16 | returnValue: '♯IV', 17 | }, 18 | { 19 | args: ['i bVII bVI'], 20 | returnValue: 'i ♭VII ♭VI', 21 | } 22 | ]) 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/keys/getDistanceOfKeys.spec.ts: -------------------------------------------------------------------------------- 1 | import { getDistanceOfKeys } from './getDistanceOfKeys'; 2 | 3 | describe('getDistanceOfKeys', () => { 4 | it('C to Db is 1 semitone', () => { 5 | expect(getDistanceOfKeys('Db', 'C')).toEqual(1); 6 | }); 7 | 8 | it('F to Bb is 5 semitones', () => { 9 | expect(getDistanceOfKeys('Bb', 'F')).toEqual(5); 10 | }); 11 | 12 | it('C to Bb should be -2 semitones', () => { 13 | expect(getDistanceOfKeys('C', 'Bb')).toEqual(2); 14 | }); 15 | 16 | it('Bb to C should be 2 semitones', () => { 17 | expect(getDistanceOfKeys('Bb', 'C')).toEqual(-2); 18 | }); 19 | }) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | .tmp 7 | *.tmp 8 | *.tmp.* 9 | *.sublime-project 10 | *.sublime-workspace 11 | .DS_Store 12 | Thumbs.db 13 | UserInterfaceState.xcuserstate 14 | $RECYCLE.BIN/ 15 | 16 | *.log 17 | log.txt 18 | npm-debug.log* 19 | 20 | /.idea 21 | /.ionic 22 | /.sass-cache 23 | /.sourcemaps 24 | /.versions 25 | /.vscode 26 | /.angular/cache 27 | /coverage 28 | /dist 29 | /node_modules 30 | /platforms 31 | /plugins 32 | /www 33 | /resources/android/icon 34 | /resources/android/splash 35 | /.vs 36 | package-lock.json 37 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/answer-indication/answer-indication.component.scss: -------------------------------------------------------------------------------- 1 | @import 'main'; 2 | 3 | :host { 4 | min-width: 5 * $unit; 5 | max-width: 10 * $unit; 6 | height: 5 * $unit; 7 | border-radius: 4px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | padding: 0.5 * $unit; 12 | text-align: center; 13 | 14 | &.--focused { 15 | border: 4px solid #c6ddff; 16 | } 17 | 18 | &.--wrong { 19 | color: var(--ion-color-danger); 20 | } 21 | 22 | &.cdk-drop-list-dragging { 23 | background-color: #efefef; 24 | } 25 | } 26 | 27 | .no-answer { 28 | opacity: 0.7; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/scaleDegreeToSolfegeNote.ts: -------------------------------------------------------------------------------- 1 | import { SolfegeNote } from './SolfegeNote'; 2 | import { ScaleDegree } from './ScaleDegrees'; 3 | import * as _ from 'lodash'; 4 | 5 | export const scaleDegreeToSolfegeNote: Record = { 6 | '1': 'Do', 7 | 'b2': 'Ra', 8 | '2': 'Re', 9 | 'b3': 'Me', 10 | '3': 'Mi', 11 | '4': 'Fa', 12 | '#4': 'Fi', 13 | '5': 'Sol', 14 | 'b6': 'Le', 15 | '6': 'La', 16 | 'b7': 'Te', 17 | '7': 'Ti', 18 | } 19 | export const solfegeNoteToScaleDegree: Record = _.invert(scaleDegreeToSolfegeNote) as Record; 20 | -------------------------------------------------------------------------------- /src/app/storage/storage.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Provider, 4 | } from '@angular/core'; 5 | import { StorageService } from './storage.service'; 6 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 7 | 8 | @Injectable() 9 | export class StorageServiceMock implements PublicMembers { 10 | async get(key: string): Promise { 11 | } 12 | 13 | async set(key: string, value: any): Promise { 14 | } 15 | 16 | static providers: Provider[] = [ 17 | StorageServiceMock, 18 | { 19 | provide: StorageService, 20 | useExisting: StorageServiceMock, 21 | }, 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/keys/isInKey.spec.ts: -------------------------------------------------------------------------------- 1 | import { isInKey } from './isInKey'; 2 | 3 | describe('isInKey', function () { 4 | it('Bb4 is in Bb major', () => { 5 | expect(isInKey('Bb4', 'Bb')).toBeTrue(); 6 | }); 7 | 8 | it('C#4 is not in Bb major', () => { 9 | expect(isInKey('C#4', 'Bb')).toBeFalse(); 10 | }); 11 | 12 | it('F3 is in Bb major', () => { 13 | expect(isInKey('F3', 'Bb')).toBeTrue(); 14 | }); 15 | 16 | it('F#3 is in G major', () => { 17 | expect(isInKey('F#3', 'G')).toBeTrue(); 18 | }); 19 | 20 | it('F5 is not in G major', () => { 21 | expect(isInKey('F5', 'G')).toBeFalse(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /release-automation/updateAndroidVersion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Version, 3 | formatVersion, 4 | } from './version'; 5 | import * as fs from 'fs'; 6 | 7 | export function updateAndroidVersion(version: Version, path: string = '../android/app/build.gradle'): void { 8 | let file = fs.readFileSync(path, 'utf8'); 9 | file = file.replace(/versionName "\d+.\d+.\d+"/, `versionName "${formatVersion(version)}"`); 10 | const versionCodeRegex = /versionCode (\d+)/; 11 | const currentVersionCode: number = +(file.match(versionCodeRegex)![1]); 12 | file = file.replace(versionCodeRegex, 'versionCode ' + (currentVersionCode + 1)); 13 | fs.writeFileSync(path, file); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/release-notes/version-comparator.spec.ts: -------------------------------------------------------------------------------- 1 | import { versionComparator } from './version-comparator'; 2 | 3 | describe('versionComparator', () => { 4 | it('1.0.0 and 1.0.0 are equal', () => { 5 | expect(versionComparator('1.0.0', '1.0.0')).toEqual(0); 6 | }); 7 | 8 | it('1.3.2 is before 1.3.3', () => { 9 | expect(versionComparator('1.3.2', '1.3.3')).toBeLessThan(0); 10 | }); 11 | 12 | it('1.3.2 is before 1.4.3', () => { 13 | expect(versionComparator('1.3.2', '1.4.3')).toBeLessThan(0); 14 | }); 15 | 16 | it('1.3.3 is after 1.1.2', () => { 17 | expect(versionComparator('1.3.3', '1.1.2')).toBeGreaterThan(0); 18 | }); 19 | }) 20 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/NoteType.spec.ts: -------------------------------------------------------------------------------- 1 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 2 | import { NoteType } from './NoteType'; 3 | import { getNoteType } from './getNoteType'; 4 | import { toNoteTypeNumber } from './toNoteTypeNumber'; 5 | 6 | export function noteOfType(noteType: NoteType): jasmine.AsymmetricMatcher { 7 | return { 8 | asymmetricMatch(note: Note, customTesters: ReadonlyArray): boolean { 9 | return toNoteTypeNumber(getNoteType(note)) === toNoteTypeNumber(noteType); 10 | }, 11 | jasmineToString(): string { 12 | return 'note of type ' + noteType; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/view-message/view-message.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { ViewMessagePage } from './view-message.page'; 5 | 6 | import { IonicModule } from '@ionic/angular'; 7 | 8 | import { ViewMessagePageRoutingModule } from './view-message-routing.module'; 9 | 10 | /** TODO: delete: left for example*/ 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FormsModule, 15 | IonicModule, 16 | ViewMessagePageRoutingModule 17 | ], 18 | declarations: [ViewMessagePage] 19 | }) 20 | export class ViewMessagePageModule {} 21 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/answer-layouts/scale-layout.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | import { ScaleDegree } from '../../../utility'; 3 | 4 | export const scaleLayout: Exercise.AnswersLayout = { 5 | rows: [ 6 | [ 7 | { 8 | answer: null, 9 | space: 0.58 10 | }, 11 | 'b2', 12 | 'b3', 13 | null, 14 | '#4', 15 | 'b6', 16 | 'b7', 17 | { 18 | answer: null, 19 | space: 0.58, 20 | }, 21 | ], 22 | [ 23 | '1', 24 | '2', 25 | '3', 26 | '4', 27 | '5', 28 | '6', 29 | '7', 30 | ], 31 | ], 32 | } 33 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/noteTypeToScaleDegree.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../../../shared/testing-utility/testPureFunction'; 2 | import { noteTypeToScaleDegree } from './noteTypeToScaleDegree'; 3 | 4 | describe('noteTypeToScaleDegree', function() { 5 | testPureFunction(noteTypeToScaleDegree, [ 6 | { 7 | args: ['Ab', 'C'], 8 | returnValue: 'b6', 9 | }, 10 | { 11 | args: ['F', 'D'], 12 | returnValue: 'b3', 13 | }, 14 | { 15 | args: ['F#', 'F#'], 16 | returnValue: '1', 17 | }, 18 | { 19 | args: ['Ab', 'Db'], 20 | returnValue: '5', 21 | }, 22 | ]) 23 | }); 24 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | ## OpenEar: Privacy policy 2 | 3 | Welcome to OpenEar app! 4 | 5 | This is an open source app developed by Shachar Har-Shuv. The source code is available on GitHub under the MIT license. 6 | 7 | This app does not collect any data, including any personal identifiable information. All data (user preferences etc) is stored locally on the device and is erased when uninstalling the app. 8 | 9 | If you find any security vulnerability that has been inadvertently caused by me, or have any question regarding how the app protects your privacy, please email me. 10 | 11 | Yours sincerely, 12 | Shachar Har-Shuv. 13 | Tel-Aviv, Israel 14 | shachar.harshuv@gmail.com 15 | -------------------------------------------------------------------------------- /android/app/capacitor.build.gradle: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN 2 | 3 | android { 4 | compileOptions { 5 | sourceCompatibility JavaVersion.VERSION_1_8 6 | targetCompatibility JavaVersion.VERSION_1_8 7 | } 8 | } 9 | 10 | apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" 11 | dependencies { 12 | implementation project(':capacitor-app') 13 | implementation project(':capacitor-haptics') 14 | implementation project(':capacitor-keyboard') 15 | implementation project(':capacitor-status-bar') 16 | 17 | } 18 | 19 | 20 | if (hasProperty('postBuildExtras')) { 21 | postBuildExtras() 22 | } 23 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/keys/getDistanceOfKeys.ts: -------------------------------------------------------------------------------- 1 | import { Key } from './Key'; 2 | import { toNoteNumber } from '../notes/toNoteName'; 3 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 4 | import { Interval } from '../intervals/Interval'; 5 | 6 | /** 7 | * Returns negative number if smaller 8 | * */ 9 | export function getDistanceOfKeys(to: Key, from: Key): number { 10 | let distance = (toNoteNumber(to + '1' as Note) - toNoteNumber(from + '1' as Note))/* % Interval.Octave*/; 11 | if (distance > 6) { 12 | distance = distance - Interval.Octave; 13 | } else if (distance < -6) { 14 | distance = distance + Interval.Octave; 15 | } 16 | return distance; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/answer-indication/answer-indication.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input, 4 | HostBinding, 5 | } from '@angular/core'; 6 | 7 | @Component({ 8 | selector: 'app-answer-indication', 9 | templateUrl: './answer-indication.component.html', 10 | styleUrls: ['./answer-indication.component.scss'], 11 | }) 12 | export class AnswerIndicationComponent { 13 | @Input() 14 | answerDisplay: string | null = null; 15 | 16 | @HostBinding('class.--focused') 17 | @Input() 18 | isFocused: boolean = false; 19 | 20 | @HostBinding('class.--wrong') 21 | @Input() 22 | wasAnsweredWrong: boolean = false; 23 | 24 | constructor() { } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/view-message/view-message.page.scss: -------------------------------------------------------------------------------- 1 | ion-item { 2 | --inner-padding-end: 0; 3 | --background: transparent; 4 | } 5 | 6 | ion-label { 7 | margin-top: 12px; 8 | margin-bottom: 12px; 9 | } 10 | 11 | ion-item h2 { 12 | font-weight: 600; 13 | } 14 | 15 | ion-item .date { 16 | float: right; 17 | align-items: center; 18 | display: flex; 19 | } 20 | 21 | ion-item ion-icon { 22 | font-size: 42px; 23 | margin-right: 8px; 24 | } 25 | 26 | ion-item ion-note { 27 | font-size: 15px; 28 | margin-right: 12px; 29 | font-weight: normal; 30 | } 31 | 32 | h1 { 33 | margin: 0; 34 | font-weight: bold; 35 | font-size: 22px; 36 | } 37 | 38 | p { 39 | line-height: 22px; 40 | } -------------------------------------------------------------------------------- /src/app/home/home.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { PlayerService } from '../services/player.service'; 3 | import { ExerciseService } from '../exercise/exercise.service'; 4 | import { Exercise } from '../exercise/Exercise'; 5 | import IExercise = Exercise.Exercise; 6 | 7 | @Component({ 8 | selector: 'app-home', 9 | templateUrl: 'home.page.html', 10 | styleUrls: ['home.page.scss'], 11 | }) 12 | export class HomePage { 13 | readonly exerciseList: IExercise[] = this._exerciseService.getExerciseList(); 14 | 15 | constructor( 16 | private readonly _player: PlayerService, 17 | private readonly _exerciseService: ExerciseService, 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/shared/animations/collapse-vertical.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnimationTriggerMetadata, 3 | style, 4 | trigger, 5 | } from '@angular/animations'; 6 | import {enterLeaveAnimationFactory} from "./enter-leave-animation-factory"; 7 | 8 | export const collapseVertical: AnimationTriggerMetadata = trigger('collapseVertical', enterLeaveAnimationFactory({ 9 | steps: [ 10 | style({ 11 | height: 0, 12 | opacity: 0, 13 | overflow: 'hidden', 14 | offset: 0, 15 | display: 'block', 16 | }), 17 | style({ 18 | height: '*', 19 | opacity: 1, 20 | overflow: 'hidden', 21 | offset: 1.0, 22 | display: 'block', 23 | }), 24 | ], 25 | })); 26 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/NotesInKeyExercise/notes-in-key-explanation/notes-in-key-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {NoteEvent} from "../../../../services/player.service"; 3 | import {IV_V_I_CADENCE_IN_C} from "../../../utility/music/chords"; 4 | 5 | @Component({ 6 | selector: 'app-notes-in-key-explanation', 7 | templateUrl: './notes-in-key-explanation.component.html', 8 | }) 9 | export class NotesInKeyExplanationComponent { 10 | resolutionOfReInC: NoteEvent[] = [ 11 | ...IV_V_I_CADENCE_IN_C, 12 | { 13 | notes: 'D3', 14 | duration: '2n.', 15 | }, 16 | { 17 | notes: 'C3', 18 | duration: '2n', 19 | } 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { IonicModule } from '@ionic/angular'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { HomePage } from './home.page'; 6 | import { HomePageRoutingModule } from './home-routing.module'; 7 | import { ExerciseSummaryComponent } from './components/exercise-summary/exercise-summary.component'; 8 | 9 | @NgModule({ 10 | imports: [ 11 | CommonModule, 12 | FormsModule, 13 | IonicModule, 14 | HomePageRoutingModule, 15 | ], 16 | declarations: [ 17 | HomePage, 18 | ExerciseSummaryComponent, 19 | ] 20 | }) 21 | export class HomePageModule {} 22 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/included-answers/included-answers.component.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 17 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/NotesWithChords/notes-with-chords-explanation/notes-with-chords-explanation.component.html: -------------------------------------------------------------------------------- 1 |

2 | Different notes can sound different with different chords underneath them. 3 | In this exercise you'll hear a chord with a high note emphasized, 4 | and you'll be prompted not only to identify the scale degree of the note (denoted with a solfege syllable), but also the chord degree. (Denoted with a number) 5 |

6 |

7 | For example: In the key of C, La5 is the 6th degree (aka A) as the 5th of a chord, which happened to be the ii chord (aka Dm) 8 |

9 | 10 | This exercise is most affective while practicing with the same scale degree and different chord degrees. 11 | 12 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReleaseNotesPage } from './release-notes-page.component'; 4 | import { RELEASE_NOTES_TOKEN, releaseNotes } from './release-notes'; 5 | import { ModalModule } from '../shared/modal/modal.module'; 6 | import { IonicModule } from '@ionic/angular'; 7 | 8 | 9 | 10 | @NgModule({ 11 | declarations: [ 12 | ReleaseNotesPage, 13 | ], 14 | providers: [ 15 | { 16 | provide: RELEASE_NOTES_TOKEN, 17 | useValue: releaseNotes, 18 | } 19 | ], 20 | imports: [ 21 | CommonModule, 22 | ModalModule, 23 | IonicModule, 24 | ] 25 | }) 26 | export class ReleaseNotesModule { } 27 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/NoteNumberOrName.ts: -------------------------------------------------------------------------------- 1 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 2 | 3 | export type NoteNumber = /*21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127*/ number; 4 | 5 | export type NoteNumberOrName = Note | NoteNumber; 6 | -------------------------------------------------------------------------------- /src/app/version.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AppVersion } from '@ionic-native/app-version/ngx'; 3 | import { Async } from './shared/ts-utility/rxjs/SyncOrAsync'; 4 | 5 | @Injectable() 6 | export class VersionService { 7 | readonly version$: Async = this._getVersion(); 8 | 9 | constructor(private _appVersion: AppVersion) { 10 | } 11 | 12 | private _getVersion(): Promise { 13 | return this._appVersion.getVersionNumber() 14 | .catch((error) => { 15 | /** 16 | * TODO: it would be healthier to never call getVersionCode when cordova is not available. 17 | * Need to figure out how to know that 18 | * */ 19 | return 'development'; 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/base-classes/base-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | AfterViewInit, 4 | } from '@angular/core'; 5 | import { 6 | ReplaySubject, 7 | Observable, 8 | } from 'rxjs'; 9 | import { BaseDestroyable } from './base-destroyable'; 10 | import { take } from 'rxjs/operators'; 11 | 12 | @Directive() 13 | export class BaseComponent extends BaseDestroyable implements AfterViewInit { 14 | private readonly _afterViewInit$ = new ReplaySubject(1); 15 | readonly afterViewInit$: Observable = this._afterViewInit$.asObservable(); 16 | readonly afterViewInitPromise: Promise = this.afterViewInit$.pipe(take(1)) 17 | .toPromise(); 18 | 19 | ngAfterViewInit(): void { 20 | this._afterViewInit$.next(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | 5 | repositories { 6 | google() 7 | jcenter() 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:4.2.1' 11 | classpath 'com.google.gms:google-services:4.3.5' 12 | 13 | // NOTE: Do not place your application dependencies here; they belong 14 | // in the individual module build.gradle files 15 | } 16 | } 17 | 18 | apply from: "variables.gradle" 19 | 20 | allprojects { 21 | repositories { 22 | google() 23 | jcenter() 24 | } 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/toNoteTypeNumber.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from './NoteType'; 2 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 3 | import { getNoteType } from './getNoteType'; 4 | import { 5 | toNoteNumber, 6 | toNoteName 7 | } from './toNoteName'; 8 | 9 | export function toNoteTypeNumber(noteType: NoteType | number): number { 10 | if (typeof noteType === 'number') { 11 | return noteType; 12 | } 13 | return toNoteNumber(noteType + '1' as Note) - toNoteNumber('C1'); 14 | } 15 | 16 | export function toNoteTypeName(noteTypeNumber: NoteType | number): NoteType { 17 | if (typeof noteTypeNumber === 'string') { 18 | return noteTypeNumber; 19 | } 20 | return getNoteType(toNoteName(toNoteNumber('C1') + noteTypeNumber)) 21 | } 22 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /src/app/release-notes/release-notes.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Provider, 4 | } from '@angular/core'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 7 | import { ReleaseNotesService } from './release-notes.service'; 8 | 9 | @Injectable() 10 | export class ReleaseNotesServiceMock implements PublicMembers { 11 | relevantReleaseNotes$ = new BehaviorSubject([]); 12 | 13 | setReleaseNotesWereViewed(): Promise { 14 | return Promise.resolve(); 15 | } 16 | 17 | static providers: Provider[] = [ 18 | ReleaseNotesServiceMock, 19 | { 20 | provide: ReleaseNotesService, 21 | useExisting: ReleaseNotesServiceMock, 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/app/home/components/exercise-summary/exercise-summary.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input 4 | } from '@angular/core'; 5 | import { Exercise } from '../../../exercise/Exercise'; 6 | import IExercise = Exercise.Exercise; 7 | import { PlayerService } from '../../../services/player.service'; 8 | 9 | @Component({ 10 | selector: 'app-exercise-summary', 11 | templateUrl: './exercise-summary.component.html', 12 | styleUrls: ['./exercise-summary.component.scss'], 13 | }) 14 | export class ExerciseSummaryComponent { 15 | @Input() 16 | exercise: IExercise; 17 | 18 | constructor(private _player: PlayerService,) { 19 | } 20 | 21 | // This has to be called by a user click event to work 22 | initAudioPlayer(): void { 23 | this._player.init(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android/capacitor.settings.gradle: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN 2 | include ':capacitor-android' 3 | project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') 4 | 5 | include ':capacitor-app' 6 | project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') 7 | 8 | include ':capacitor-haptics' 9 | project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') 10 | 11 | include ':capacitor-keyboard' 12 | project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') 13 | 14 | include ':capacitor-status-bar' 15 | project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') 16 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/observable-spy/observable-spy-matchers.ts: -------------------------------------------------------------------------------- 1 | import CustomMatcherFactories = jasmine.CustomMatcherFactories; 2 | import { Observable } from 'rxjs'; 3 | import { toHaveLastEmitted } from './matchers/to-have-last-emitted'; 4 | import { toHaveOnlyEmitted } from './matchers/to-have-only-emitted'; 5 | import { toHaveHadEmissions } from './matchers/to-have-had-emissions'; 6 | 7 | declare global { 8 | function expect(spy: Observable): jasmine.ObservableMatchers; 9 | 10 | namespace jasmine { 11 | interface ObservableMatchers extends jasmine.Matchers { 12 | not: ObservableMatchers; 13 | } 14 | } 15 | } 16 | 17 | export const observableSpyMatchers: CustomMatcherFactories = { 18 | toHaveLastEmitted, 19 | toHaveOnlyEmitted, 20 | toHaveHadEmissions, 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/settings/PlayAfterCorrectAnswerSetting.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | 3 | export type PlayAfterCorrectAnswerSetting = { 4 | playAfterCorrectAnswer: boolean; 5 | } 6 | 7 | export const playAfterCorrectAnswerControlDescriptorList = (param?: { 8 | show?: (settings: GSettings) => boolean, 9 | }): Exercise.SettingsControlDescriptor[] => ([ 10 | { 11 | key: 'playAfterCorrectAnswer', 12 | show: param?.show || undefined, 13 | info: 'After correct answer was clicked the app will play a short segment of music to enforce your memory.
This is recommended for beginners.', 14 | descriptor: { 15 | controlType: 'checkbox', 16 | label: `Play Resolution`, 17 | }, 18 | } 19 | ]); 20 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/settings/NumberOfSegmentsSetting.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from '../../../Exercise'; 2 | import { withSettings } from './withSettings'; 3 | 4 | export type NumberOfSegmentsSetting = { 5 | numberOfSegments: number; 6 | }; 7 | 8 | export const numberOfSegmentsControlDescriptorList = (name: string): Exercise.SettingsControlDescriptor[] => ([ 9 | { 10 | key: 'numberOfSegments', 11 | descriptor: { 12 | controlType: 'slider', 13 | label: `Number of ${name}`, 14 | min: 1, 15 | max: 8, 16 | step: 1, 17 | }, 18 | } 19 | ]); 20 | 21 | export const numberOfSegmentsSettings = (name: string) => withSettings({ 22 | settingsDescriptors: numberOfSegmentsControlDescriptorList(name), 23 | defaultSettings: { 24 | numberOfSegments: 1, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/app/home/home.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | import { RouterModule } from '@angular/router'; 4 | import { HomePage } from './home.page'; 5 | 6 | xdescribe('HomePage', () => { 7 | let component: HomePage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ HomePage ], 13 | imports: [IonicModule.forRoot(), RouterModule.forRoot([])] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(HomePage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/shared/modal/modal-frame/modal-frame.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ModalController } from '@ionic/angular'; 3 | 4 | @Component({ 5 | selector: 'app-modal-frame', 6 | templateUrl: './modal-frame.component.html', 7 | styleUrls: ['./modal-frame.component.scss'], 8 | exportAs: 'modal', 9 | }) 10 | export class ModalFrameComponent { 11 | @Input() 12 | title: string; 13 | 14 | @Input() 15 | padding: boolean = true; 16 | 17 | @Input() 18 | closeIcon: string = 'close-outline'; 19 | 20 | @Input() 21 | onClose: () => Promise; 22 | 23 | constructor( 24 | private _modalController: ModalController, 25 | ) { } 26 | 27 | async close(): Promise { 28 | await this._modalController.dismiss(this.onClose ? await this.onClose() : undefined); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/app/view-message/view-message.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-view-message', 6 | templateUrl: './view-message.page.html', 7 | styleUrls: ['./view-message.page.scss'], 8 | }) 9 | export class ViewMessagePage implements OnInit { 10 | public message: any; 11 | 12 | constructor( 13 | private data: any, 14 | private activatedRoute: ActivatedRoute 15 | ) { } 16 | 17 | ngOnInit() { 18 | const id = this.activatedRoute.snapshot.paramMap.get('id'); 19 | this.message = this.data.getMessageById(parseInt(id!, 10)); 20 | } 21 | 22 | getBackButtonText() { 23 | const win = window as any; 24 | const mode = win && win.Ionic && win.Ionic.mode; 25 | return mode === 'ios' ? 'Inbox' : ''; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/jasmine/custom-matchers/init-custom-matchers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Additions to jasmine built in declaration 3 | * Used for custom matchers 4 | * */ 5 | import { spyMatchers } from './spy-matchers/spy-matchers'; 6 | import { observableSpyMatchers } from '../../../ts-utility/rxjs/observable-spy/observable-spy-matchers'; 7 | 8 | declare global { 9 | namespace jasmine { 10 | interface MatchersUtil { 11 | equals(a: any, b: any, customTesters?: ReadonlyArray | DiffBuilder): boolean; 12 | } 13 | 14 | const matchers: { 15 | toEqual(util: MatchersUtil): CustomMatcher; 16 | }; 17 | } 18 | } 19 | 20 | export function initCustomMatchers(): void { 21 | beforeAll(() => { 22 | jasmine.addMatchers({ 23 | ...spyMatchers, 24 | ...observableSpyMatchers, 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.scss: -------------------------------------------------------------------------------- 1 | @use 'main' as *; 2 | 3 | :host { 4 | display: contents; 5 | } 6 | 7 | .answers-layout { 8 | &__answers-rows-container, 9 | &__answers-buttons-auto-layout-container { 10 | flex-grow: 1; 11 | } 12 | 13 | &__answers-buttons-auto-layout-container { 14 | display: flex; 15 | flex-wrap: wrap; 16 | align-content: flex-start; 17 | 18 | ion-button { 19 | flex: 1; 20 | } 21 | } 22 | 23 | &__answers-row { 24 | display: flex; 25 | gap: $unit / 2; 26 | margin-bottom: $unit / 2; 27 | height: 4.5 * $unit; 28 | align-items: center; 29 | 30 | > * { 31 | min-width: 0; 32 | flex: 1; 33 | } 34 | } 35 | 36 | &__cell { 37 | ::ng-deep * { 38 | width: 100%; 39 | margin: 0; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/rxjs/publishReplayUntilAndConnect.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MonoTypeOperatorFunction, 3 | Observable, 4 | ConnectableObservable, 5 | Subscription 6 | } from 'rxjs'; 7 | import { 8 | publishReplay, 9 | take 10 | } from 'rxjs/operators'; 11 | 12 | export function publishReplayUntilAndConnect(notifier?: Observable): MonoTypeOperatorFunction { 13 | return (source$: Observable) => { 14 | const connectableObservable: ConnectableObservable = source$ 15 | .pipe( 16 | publishReplay(1), 17 | ) as ConnectableObservable; 18 | const subscription: Subscription = connectableObservable.connect(); 19 | if (notifier) { 20 | notifier.pipe( 21 | take(1), 22 | ) 23 | .subscribe(() => subscription.unsubscribe()); 24 | } 25 | 26 | return connectableObservable; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "module": "es2022", 15 | "lib": ["es2018", "dom"], 16 | "strictNullChecks": true, 17 | "skipLibCheck": true, 18 | "noImplicitOverride": true, 19 | "noImplicitThis": true, 20 | }, 21 | "angularCompilerOptions": { 22 | "enableI18nLegacyMessageIdFormat": false, 23 | "strictInjectionParameters": true, 24 | "strictInputAccessModifiers": true, 25 | "strictTemplates": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/about/about.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import {AboutPage} from "./about.page"; 4 | import {RouterModule} from "@angular/router"; 5 | import {IonicModule} from "@ionic/angular"; 6 | import {SharedComponentsModule} from "../shared/components/shared-components/shared-components.module"; 7 | import {AppVersion} from "@ionic-native/app-version/ngx"; 8 | import { ModalModule } from '../shared/modal/modal.module'; 9 | 10 | 11 | 12 | @NgModule({ 13 | declarations: [ 14 | AboutPage, 15 | ], 16 | imports: [ 17 | CommonModule, 18 | IonicModule, 19 | SharedComponentsModule, 20 | RouterModule.forChild([ 21 | { 22 | path: '', 23 | component: AboutPage, 24 | } 25 | ]), 26 | ModalModule 27 | ], 28 | }) 29 | export class AboutModule { } 30 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/shared-components.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ContentPaddingDirective } from './content-padding.directive'; 4 | import { PlayOnClickDirective } from './play-on-click.directive'; 5 | import { InfoPanelComponent } from './info-panel/info-panel.component'; 6 | import { CollapsibleComponent } from './collapsible/collapsible.component'; 7 | 8 | @NgModule({ 9 | declarations: [ 10 | ContentPaddingDirective, 11 | PlayOnClickDirective, 12 | InfoPanelComponent, 13 | CollapsibleComponent, 14 | ], 15 | exports: [ 16 | ContentPaddingDirective, 17 | PlayOnClickDirective, 18 | InfoPanelComponent, 19 | CollapsibleComponent, 20 | ], 21 | imports: [ 22 | CommonModule, 23 | ], 24 | }) 25 | export class SharedComponentsModule { 26 | } 27 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/Mode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ScaleDegree, 3 | scaleDegreeToChromaticDegree, 4 | chromaticDegreeToScaleDegree, 5 | } from '../scale-degrees'; 6 | import { mod } from '../../../../shared/ts-utility/mod'; 7 | 8 | export enum Mode { 9 | Ionian = 1, 10 | Dorian, 11 | Phrygian, 12 | Lydian, 13 | Mixolydian, 14 | Aeolian, 15 | Locrian, 16 | Major = Ionian, 17 | Minor = Aeolian, 18 | } 19 | 20 | export function toRelativeMode(scaleDegree: ScaleDegree, source: Mode, target: Mode): ScaleDegree { 21 | if (source === target) { 22 | return scaleDegree; 23 | } 24 | let distance: number = scaleDegreeToChromaticDegree[source] - scaleDegreeToChromaticDegree[target]; 25 | const chromaticScaleDegree: number = scaleDegreeToChromaticDegree[scaleDegree]; 26 | return chromaticDegreeToScaleDegree[mod(chromaticScaleDegree + distance - 1, 12) + 1]; 27 | } 28 | -------------------------------------------------------------------------------- /src/app/shared/components/shared-components/play-on-click.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, HostListener, Input} from '@angular/core'; 2 | import {NoteEvent, PlayerService} from "../../../services/player.service"; 3 | import {OneOrMany} from "../../ts-utility"; 4 | import {NoteNumberOrName} from "../../../exercise/utility/music/notes/NoteNumberOrName"; 5 | import {toSteadyPart} from "../../../exercise/utility"; 6 | import * as _ from "lodash"; 7 | 8 | @Directive({ 9 | selector: '[playOnClick]' 10 | }) 11 | export class PlayOnClickDirective { 12 | @Input('playOnClick') 13 | part: OneOrMany | NoteEvent>; 14 | 15 | constructor(private _player: PlayerService) { } 16 | 17 | @HostListener('click') 18 | onClick(): void { 19 | if (_.isEmpty(this.part)) { 20 | return; 21 | } 22 | this._player.playPart(toSteadyPart(this.part)); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/IntervalExercise/interval-exercise-explanation/interval-exercise-explanation.component.html: -------------------------------------------------------------------------------- 1 |

2 | In the following exercise two notes will be played, and you will be required 3 | to specify the interval between them. 4 |

5 | 6 | Tip! Try to recall songs / melodies you know that starts which each interval. 7 | 8 |

9 | If you're just starting out, it's recommended to limit to amount of possible 10 | intervals. That can be done in the exercise settings () 11 |

12 | 13 |

List of Intervals:

14 | 15 |

(Click to play)

16 | 17 | 18 | 19 | 20 | {{interval.name}} 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/app/storage/storage-migration.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Provider, 4 | } from '@angular/core'; 5 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 6 | import { 7 | StorageMigrationScript, 8 | StorageMigrationService, 9 | } from './storage-migration.service'; 10 | 11 | @Injectable() 12 | export class StorageMigrationServiceMock implements PublicMembers { 13 | async getScriptsToRun(): Promise { 14 | return []; 15 | } 16 | 17 | async runMigrationScript(migrationScript: StorageMigrationScript): Promise { 18 | } 19 | 20 | async runMigrationScripts(): Promise { 21 | } 22 | 23 | static providers: Provider[] = [ 24 | StorageMigrationServiceMock, 25 | { 26 | provide: StorageMigrationService, 27 | useExisting: StorageMigrationServiceMock, 28 | }, 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.mock.service.ts: -------------------------------------------------------------------------------- 1 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 2 | import { ExerciseService } from './exercise.service'; 3 | import { Injectable, Provider } from '@angular/core'; 4 | import { Exercise } from './Exercise'; 5 | import { MockExercise } from './MockExercise'; 6 | 7 | @Injectable() 8 | export class ExerciseMockService implements PublicMembers { 9 | static mockExercise: Exercise.Exercise = MockExercise.create(); 10 | 11 | getExercise(id: string): Exercise.Exercise { 12 | return ExerciseMockService.mockExercise; 13 | } 14 | 15 | getExerciseList(): Exercise.Exercise[] { 16 | return [ExerciseMockService.mockExercise]; 17 | } 18 | 19 | static providers: Provider[] = [ 20 | ExerciseMockService, 21 | { 22 | provide: ExerciseService, 23 | useExisting: ExerciseMockService, 24 | } 25 | ] 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/collectionChain.ts: -------------------------------------------------------------------------------- 1 | import { 2 | map, 3 | filter, 4 | toPairs, 5 | orderBy, 6 | groupBy, 7 | sortBy, 8 | mapValues, 9 | keyBy, 10 | } from 'lodash'; 11 | 12 | const collectionChainableFunctions = { 13 | map, 14 | filter, 15 | toPairs, 16 | orderBy, 17 | groupBy, 18 | sortBy, 19 | keyBy, 20 | mapValues, 21 | }; 22 | 23 | // idea taken from: https://github.com/lodash/lodash/issues/3298#issuecomment-341685354 24 | export const collectionChain = (input: G[]) => { 25 | let value: any = input; 26 | const wrapper = { 27 | ...mapValues( 28 | collectionChainableFunctions, 29 | (f: any) => (...args: any[]) => { 30 | // lodash always puts input as the first argument 31 | value = f(value, ...args); 32 | return wrapper; 33 | }, 34 | ), 35 | value: () => value, 36 | } as const; 37 | return wrapper; 38 | }; 39 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 17 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /ios/App/Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '12.0' 2 | use_frameworks! 3 | 4 | # workaround to avoid Xcode caching of Pods that requires 5 | # Product -> Clean Build Folder after new Cordova plugins installed 6 | # Requires CocoaPods 1.6 or newer 7 | install! 'cocoapods', :disable_input_output_paths => true 8 | 9 | def capacitor_pods 10 | pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' 11 | pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' 12 | pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' 13 | pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' 14 | pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' 15 | pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' 16 | pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' 17 | end 18 | 19 | target 'App' do 20 | capacitor_pods 21 | # Add your Pods here 22 | end 23 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/CommonChordProgressionExercise/common-chord-progressions-explanation/common-chord-progressions-explanation.component.html: -------------------------------------------------------------------------------- 1 | 2 | If you haven't already - check out the Chord in Key exercise for a basic introduction to chord progressions and Roman Analysis. 3 | 4 | 5 |

6 | In this exercise, you can choose from a set of predefined progressions to practice. 7 |

8 |

9 | The available progressions are one of the most popular chord progressions in popular music. If you think there is an important progression we missed, feel free to 10 | open an issue in Github or create a pull request 11 |

12 | 13 | 14 | Tip! Try to think of songs you know that use each progression. 15 | 16 | -------------------------------------------------------------------------------- /src/app/services/drone-player.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import * as Tone from 'tone'; 3 | import { Synth } from 'tone'; 4 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class DronePlayerService { 10 | private _drone: Synth = this._getInstrument(); 11 | private _lastNote: Note | null = null; 12 | 13 | constructor() { 14 | } 15 | 16 | private _getInstrument(): Synth { 17 | return new Tone.Synth({ 18 | oscillator: { 19 | type: 'triangle' 20 | } 21 | }).toDestination(); 22 | } 23 | 24 | startDrone(note: Note): void { 25 | if (this._lastNote === note) { 26 | return; 27 | } 28 | this.stopDrone(); 29 | this._lastNote = note; 30 | this._drone.triggerAttack(note); 31 | } 32 | 33 | stopDrone(): void { 34 | this._lastNote = null; 35 | this._drone.triggerRelease(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/BaseComponentDebugger.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture } from '@angular/core/testing'; 2 | import { Spectator } from '@ngneat/spectator'; 3 | import { DebugElement } from '@angular/core'; 4 | 5 | export abstract class BaseComponentDebugger { 6 | readonly spectator: Spectator = new Spectator( 7 | this.fixture, 8 | this.debugElement, 9 | this.componentInstance, 10 | this.nativeElement, 11 | ); 12 | 13 | get componentInstance(): GComponent { 14 | return this.fixture.componentInstance; 15 | } 16 | 17 | get nativeElement(): HTMLElement { 18 | return this.fixture.nativeElement; 19 | } 20 | 21 | get debugElement(): DebugElement { 22 | return this.fixture.debugElement; 23 | } 24 | 25 | constructor(public readonly fixture: ComponentFixture) { 26 | } 27 | 28 | detectChanges(): void { 29 | this.spectator.detectChanges(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/storage/migration-scripts/storage-migration-1.3.2.ts: -------------------------------------------------------------------------------- 1 | import { StorageMigrationScript } from '../storage-migration.service'; 2 | import * as _ from 'lodash'; 3 | 4 | export const migrationScript_1_3_2: StorageMigrationScript> = { 5 | storageKey: 'exerciseSettings', 6 | breakingChangeVersion: '1.3.2', 7 | getNewData(oldData) { 8 | return _.mapValues(oldData, exerciseSettings => { 9 | if (!exerciseSettings.exerciseSettings?.includedAnswers) { 10 | return exerciseSettings; 11 | } 12 | return { 13 | ...exerciseSettings, 14 | exerciseSettings: { 15 | ...exerciseSettings.exerciseSettings, 16 | includedAnswers: _.map(exerciseSettings.exerciseSettings.includedAnswers, answer => { 17 | return answer.replace('♭', 'b').replace('ᵒ', 'dim'); 18 | }), 19 | }, 20 | } 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/style/overrides/bdc-walk-popup.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: consider cloning this library and replace all angular material components with ionic 3 | * Since both this library and this code are MIT licensed it shouldn't be a problem 4 | * (It will also be possible to publish it as a separate node module so it will be useful for others 5 | * The result will be a more consistent UI without the need for this "workaround" overrides. 6 | */ 7 | body { 8 | div.mat-menu-panel.bdc-walk-popup { 9 | .title .header { 10 | color: var(--ion-color-primary); 11 | } 12 | 13 | .container { 14 | background-color: white; 15 | } 16 | 17 | .buttons button { 18 | color: white; 19 | background-color: var(--ion-color-primary); 20 | border: none; 21 | font-weight: inherit; 22 | } 23 | } 24 | 25 | // for some reason this is transparent / white on android 26 | .mat-menu-content { 27 | color: black; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.getcapacitor.myapp; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import android.content.Context; 6 | import androidx.test.ext.junit.runners.AndroidJUnit4; 7 | import androidx.test.platform.app.InstrumentationRegistry; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * @see Testing documentation 15 | */ 16 | @RunWith(AndroidJUnit4.class) 17 | public class ExampleInstrumentedTest { 18 | 19 | @Test 20 | public void useAppContext() throws Exception { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); 23 | 24 | assertEquals("com.getcapacitor.app", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/Mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../../../shared/testing-utility/testPureFunction'; 2 | import { 3 | toRelativeMode, 4 | Mode, 5 | } from './Mode'; 6 | 7 | describe('toRelativeMode', () => { 8 | testPureFunction(toRelativeMode, [ 9 | { 10 | args: ['1', Mode.Major, Mode.Minor], 11 | returnValue: 'b3', 12 | }, 13 | { 14 | args: ['1', Mode.Minor, Mode.Major], 15 | returnValue: '6', 16 | }, 17 | { 18 | args: ['3', Mode.Major, Mode.Dorian], 19 | returnValue: '2', 20 | }, 21 | { 22 | args: ['5', Mode.Dorian, Mode.Aeolian], 23 | returnValue: '1', 24 | }, 25 | { 26 | args: ['b2', Mode.Phrygian, Mode.Locrian], 27 | returnValue: '#4', // should actually be b5, but we'll do with that for now 28 | }, 29 | { 30 | args: ['2', Mode.Phrygian, Mode.Locrian], 31 | returnValue: '5', 32 | }, 33 | ]) 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/LogReturnValue.ts: -------------------------------------------------------------------------------- 1 | export function LogReturnValue(label?: string): MethodDecorator { 2 | return function(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void { 3 | const childFunction = descriptor.value; 4 | descriptor.value = function(...args: any[]) { 5 | const returnedValue = childFunction.apply(this, args); 6 | console.log(label || '', propertyKey, returnedValue); 7 | return returnedValue; 8 | }; 9 | }; 10 | } 11 | 12 | export function LogAsyncReturnValue(label?: string): MethodDecorator { 13 | return function(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void { 14 | const childFunction = descriptor.value; 15 | descriptor.value = async function(...args: any[]) { 16 | const returnedValue = await childFunction.apply(this, args); 17 | console.log(label || '', propertyKey, returnedValue); 18 | return returnedValue; 19 | }; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/IntervalExercise/interval-exercise-explanation/interval-exercise-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { 3 | IntervalDescriptor, 4 | intervalDescriptorList, 5 | } from "../intervalExercise"; 6 | import { NoteEvent } from "../../../../services/player.service"; 7 | import { 8 | OneOrMany, 9 | toNoteNumber, 10 | } from "../../../utility"; 11 | import { NoteNumberOrName } from "../../../utility/music/notes/NoteNumberOrName"; 12 | 13 | @Component({ 14 | selector: 'app-interval-exercise-explanation', 15 | templateUrl: './interval-exercise-explanation.component.html', 16 | }) 17 | export class IntervalExerciseExplanationComponent { 18 | readonly intervalDescriptorList: (IntervalDescriptor & { toPlay: OneOrMany | NoteEvent> })[] = intervalDescriptorList.map(interval => ({ 19 | ...interval, 20 | toPlay: ['C4', toNoteNumber('C4') + interval.semitones], 21 | })); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/shared/ionic-testing/services/alert-controller.mock.ts: -------------------------------------------------------------------------------- 1 | import { PublicMembers } from '../../ts-utility/PublicMembers'; 2 | import { 3 | AlertController, 4 | AlertOptions, 5 | } from '@ionic/angular'; 6 | import { 7 | Injectable, 8 | Provider, 9 | } from '@angular/core'; 10 | 11 | @Injectable() 12 | export class AlertControllerMock implements PublicMembers { 13 | static providers: Provider[] = [ 14 | AlertControllerMock, 15 | { 16 | provide: AlertController, 17 | useExisting: AlertControllerMock 18 | } 19 | ] 20 | 21 | create(opts?: AlertOptions): Promise { 22 | throw new Error('Method not implemented.'); 23 | } 24 | 25 | dismiss(data?: any, role?: string, id?: string): Promise { 26 | throw new Error('Method not implemented.'); 27 | } 28 | 29 | getTop(): Promise { 30 | throw new Error('Method not implemented.'); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/app/shared/ionic-testing/services/modal-controller.mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Provider, 4 | } from '@angular/core'; 5 | import { 6 | ModalController, 7 | ModalOptions, 8 | } from '@ionic/angular'; 9 | import { PublicMembers } from '../../ts-utility/PublicMembers'; 10 | 11 | @Injectable() 12 | export class ModalControllerMock implements PublicMembers { 13 | create(opts: ModalOptions): Promise { 14 | return Promise.resolve({} as HTMLIonModalElement); 15 | } 16 | 17 | dismiss(data: any, role: string | undefined, id: string | undefined): Promise { 18 | return Promise.resolve(false); 19 | } 20 | 21 | getTop(): Promise { 22 | return Promise.resolve(undefined); 23 | } 24 | 25 | static providers: Provider[] = [ 26 | ModalControllerMock, 27 | { 28 | provide: ModalController, 29 | useExisting: ModalControllerMock 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/app/shared/ionic-testing/services/toaster-controller.mock.ts: -------------------------------------------------------------------------------- 1 | import { PublicMembers } from '../../ts-utility/PublicMembers'; 2 | import { 3 | ToastController, 4 | ToastOptions, 5 | } from '@ionic/angular'; 6 | import { 7 | Injectable, 8 | Provider, 9 | } from '@angular/core'; 10 | 11 | @Injectable() 12 | export class ToastControllerMock implements PublicMembers { 13 | static providers: Provider[] = [ 14 | ToastControllerMock, 15 | { 16 | provide: ToastController, 17 | useExisting: ToastControllerMock, 18 | }, 19 | ] 20 | 21 | create(opts?: ToastOptions): Promise { 22 | throw new Error('Method not implemented.'); 23 | } 24 | 25 | dismiss(data?: any, role?: string, id?: string): Promise { 26 | throw new Error('Method not implemented.'); 27 | } 28 | 29 | getTop(): Promise { 30 | throw new Error('Method not implemented.'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/harmony/RomanNumeralChordSymbol.ts: -------------------------------------------------------------------------------- 1 | import { ChordType } from '../chords'; 2 | 3 | type UppercaseRomanNumeralChordSymbol = 'I' | 'bII' | 'II' | 'bIII' | 'III' | 'IV' | '#IV' | 'V' | 'bVI' | 'VI' | 'bVII' | 'VII'; 4 | type LowercaseRomanNumeralChordSymbol = 'i' | 'bii' | 'ii' | 'biii' | 'iii' | 'iv' | '#iv' | 'v' | 'bvi' | 'vi' | 'bvii' | 'vii'; 5 | export type MajorChordTypesPostfix = '' | ChordType.Major7th | ChordType.Dominant7th | ChordType.Sus4 | ChordType.Sus2 | ChordType.Major6th | ChordType.Augmented | ChordType.MajorAdd9 | ChordType.Dominant9th | ChordType.Dominant7thSharp9th | ChordType.MajorAddSharp4; 6 | export type MinorChordTypesPostfix = '' | ChordType.Diminished | '7' | ChordType.HalfDiminished7th | ChordType.Diminished7th | '6' | ChordType.Major7th | 'M9' | ChordType.MajorAdd9; 7 | 8 | export type RomanNumeralChordSymbol = `${UppercaseRomanNumeralChordSymbol}${MajorChordTypesPostfix}` | `${LowercaseRomanNumeralChordSymbol}${MinorChordTypesPostfix}`; 9 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/utility/exerciseAttributes/composeExercise.ts: -------------------------------------------------------------------------------- 1 | import { composeWithMerge } from '../../../../shared/ts-utility/compose'; 2 | import { Exercise } from '../../../Exercise'; 3 | import { 4 | StaticOrGetter, 5 | toGetter, 6 | } from '../../../../shared/ts-utility'; 7 | 8 | // todo: consider if we can make passing "createExercise" to this, currently we had issues with typing inference 9 | export const composeExercise = composeWithMerge({ 10 | defaultSettings: (value1: Exercise.Settings, value2: Exercise.Settings) => ({ 11 | ...value1, 12 | ...value2, 13 | }), 14 | settingsDescriptors: ( 15 | value1: StaticOrGetter[], [Exercise.Settings]>, 16 | value2: StaticOrGetter[], [Exercise.Settings]>, 17 | ) => { 18 | return (settings) => [ 19 | ...toGetter(value1)(settings), 20 | ...toGetter(value2)(settings), 21 | ]; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/answers-layout/answers-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, TemplateRef } from '@angular/core'; 2 | import { Exercise } from '../../../Exercise'; 3 | 4 | @Component({ 5 | selector: 'app-answers-layout', 6 | templateUrl: './answers-layout.component.html', 7 | styleUrls: ['./answers-layout.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class AnswersLayoutComponent { 11 | @Input() 12 | answerList: Exercise.AnswerList; 13 | 14 | @Input() 15 | buttonTemplate: TemplateRef>; 16 | 17 | get isAutoLayout() { 18 | return Array.isArray(this.answerList); 19 | } 20 | 21 | readonly normalizeAnswerLayoutCellConfig = Exercise.normalizeAnswerConfig; 22 | 23 | isString(row: (Exercise.Answer | Exercise.AnswerConfig | null)[] | string): row is string { 24 | return typeof row === 'string'; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-help/exercise-explanation/exercise-explanation-content.directive.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFactoryResolver, Directive, ElementRef, Input, ViewContainerRef} from '@angular/core'; 2 | import {Exercise} from "../../../../Exercise"; 3 | 4 | @Directive({ 5 | selector: '[exerciseExplanationContent]' 6 | }) 7 | export class ExerciseExplanationContentDirective { 8 | 9 | @Input('exerciseExplanationContent') 10 | set content(content: Exercise.ExerciseExplanationContent) { 11 | if (typeof content === 'string') { 12 | this._eRef.nativeElement.parentElement.innerHTML = content; 13 | } else { 14 | this._viewContainerRef.clear(); 15 | this._viewContainerRef.createComponent( 16 | this._cfResolver.resolveComponentFactory(content), 17 | ); 18 | } 19 | } 20 | 21 | constructor( 22 | private _eRef: ElementRef, 23 | private _viewContainerRef: ViewContainerRef, 24 | private _cfResolver: ComponentFactoryResolver, 25 | ) { 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/view-message/view-message.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | import { RouterModule } from '@angular/router'; 4 | import { ViewMessagePageRoutingModule } from './view-message-routing.module'; 5 | 6 | import { ViewMessagePage } from './view-message.page'; 7 | 8 | xdescribe('ViewMessagePage', () => { 9 | let component: ViewMessagePage; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(waitForAsync(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [ ViewMessagePage ], 15 | imports: [IonicModule.forRoot(), ViewMessagePageRoutingModule, RouterModule.forRoot([])] 16 | }).compileComponents(); 17 | 18 | fixture = TestBed.createComponent(ViewMessagePage); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | })); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/ChordTypeInKeyExercise/chord-type-in-key-explanation/chord-type-in-key-explanation.component.html: -------------------------------------------------------------------------------- 1 |

2 | In this exercise you will be required to identify the type of a chord being played (Like Major vs Minor). 3 |

4 | 5 | 6 | Tip! Major chords tend to sound "brighter" and minor chords tend to sound "darker". 7 | But beware - the feeling of the chord also depends on context. 8 | 9 |

10 | By default, all chords in this exercise will be from the same key (i.e. diatonic) - 11 | This is recommended as it simulates a more musical scenario where chords are not random. You can change that in the settings. 12 |

13 | 14 |

List of Chord Types:

15 | 16 |

(Click to play)

17 | 18 | 19 | 20 | {{chord.displayName}} ({{chord.notesInC.join('-')}}) 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | import { initCustomMatchers } from './app/shared/testing-utility/jasmine/custom-matchers/init-custom-matchers'; 10 | 11 | declare const require: { 12 | context(path: string, deep?: boolean, filter?: RegExp): { 13 | keys(): string[]; 14 | (id: string): T; 15 | }; 16 | }; 17 | 18 | // First, initialize the Angular testing environment. 19 | getTestBed().initTestEnvironment( 20 | BrowserDynamicTestingModule, 21 | platformBrowserDynamicTesting(), { 22 | teardown: { destroyAfterEach: false } 23 | } 24 | ); 25 | 26 | initCustomMatchers(); 27 | 28 | // Then we find all the tests. 29 | const context = require.context('./', true, /\.spec\.ts$/); 30 | // And load the modules. 31 | context.keys().map(context); 32 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/TriadInversionExercise/triad-inversion-explanation/triad-inversion-explanation.component.html: -------------------------------------------------------------------------------- 1 |

2 | In this exercise you will hear a triad in one of the three possible inversions, 3 | and you will need to identify what inversion it is. 4 |

5 |

6 | An inversion is the order of which the chord notes are played (from lowest to highest). 7 |

8 |

9 | Here are the possible inversions: 10 |

11 |
    12 |
  • Root position (Example: C E G). Also called: 5th position
  • 13 |
  • First inversion (Example: E G C). Also called: Octave position
  • 14 |
  • Second inversion (Example: G C E). Also called: 3rd position
  • 15 |
16 | 17 | If you're just starting out, check out the "Arpeggiate Speed" field in the settings. 18 | 19 | 20 | 24 | -------------------------------------------------------------------------------- /ios/App/App/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/app/services/exercise-settings-data.mock.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Provider } from '@angular/core'; 2 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 3 | import { ExerciseSettingsDataService } from './exercise-settings-data.service'; 4 | import { ExerciseSettingsData } from '../exercise/utility'; 5 | 6 | @Injectable() 7 | export class ExerciseSettingsDataMockService implements PublicMembers { 8 | readonly exerciseIdToSettings: {[id in string]: Partial} = {}; 9 | 10 | async getExerciseSettings(exerciseId: string): Promise | undefined> { 11 | return this.exerciseIdToSettings[exerciseId]; 12 | } 13 | 14 | async saveExerciseSettings(exerciseId: string, settings: Partial): Promise { 15 | this.exerciseIdToSettings[exerciseId] = settings; 16 | } 17 | 18 | static providers: Provider[] = [ 19 | ExerciseSettingsDataMockService, 20 | { 21 | provide: ExerciseSettingsDataService, 22 | useExisting: ExerciseSettingsDataMockService, 23 | }, 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/ChordInKeyExercise/chord-in-key-explanation/chord-in-key-explanation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NoteEvent } from '../../../../services/player.service'; 3 | import { Chord, ChordSymbol, IV_V_I_CADENCE_IN_C, TriadInversion } from '../../../utility/music/chords'; 4 | 5 | @Component({ 6 | selector: 'app-chord-in-key-explanation', 7 | templateUrl: './chord-in-key-explanation.component.html', 8 | }) 9 | export class ChordInKeyExplanationComponent { 10 | getChordExample(chordSymbol: ChordSymbol, topVoicesInversion: TriadInversion): NoteEvent[] { 11 | return [ 12 | ...IV_V_I_CADENCE_IN_C, 13 | { 14 | notes: [], 15 | duration: '4n', 16 | }, 17 | { 18 | notes: new Chord(chordSymbol).getVoicing({topVoicesInversion}), 19 | velocity: 0.3, 20 | duration: '1n', 21 | }, 22 | ]; 23 | } 24 | 25 | readonly cadenceAndIChord: NoteEvent[] = this.getChordExample('C', TriadInversion.Octave); 26 | 27 | readonly cadenceAndVChord: NoteEvent[] = this.getChordExample('G', TriadInversion.Third); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/toNoteName.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toNoteName, 3 | toNoteNumber 4 | } from './toNoteName'; 5 | 6 | describe('noteNumberToNoteName', function () { 7 | it('A0', () => { 8 | expect(toNoteName(21)).toEqual('A0'); 9 | }); 10 | it('C4', () => { 11 | expect(toNoteName(60)).toEqual('C4'); 12 | }); 13 | it('G9', () => { 14 | expect(toNoteName(127)).toEqual('G9'); 15 | }); 16 | it('Note name as input', () => { 17 | expect(toNoteName('C4')).toEqual('C4'); 18 | }) 19 | }); 20 | 21 | describe('noteNameToNoteNumber', function () { 22 | it('A0', () => { 23 | expect(toNoteNumber('A0')).toEqual(21); 24 | }); 25 | 26 | it('C4', () => { 27 | expect(toNoteNumber('C4')).toEqual(60); 28 | }); 29 | 30 | it('G9', () => { 31 | expect(toNoteNumber('G9')).toEqual(127); 32 | }); 33 | 34 | it('Ab4', () => { 35 | expect(toNoteNumber('Ab4')).toEqual(68); 36 | }); 37 | 38 | it('G#4', () => { 39 | expect(toNoteNumber('Ab4')).toEqual(68); 40 | }); 41 | 42 | it('Note number as input', () => { 43 | expect(toNoteNumber(60)).toEqual(60); 44 | }) 45 | }); 46 | -------------------------------------------------------------------------------- /src/app/exercise/exercises/ChordTypeInKeyExercise/chordTypeInKeyExercise.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChordTypeInKeySettings, 3 | chordTypeExercise, 4 | } from './chordTypeInKeyExercise'; 5 | import { testExercise } from '../testing-utility/test-exercise.spec'; 6 | import { expectedVoicingSettingsDescriptors } from '../utility/exerciseAttributes/chordProgressionExercise.spec'; 7 | import { defaultTonalExerciseSettings } from '../utility/exerciseAttributes/tonalExercise.spec'; 8 | 9 | describe(chordTypeExercise.name, () => { 10 | const context = testExercise({ 11 | getExercise: () => chordTypeExercise(), 12 | settingDescriptorList: [ 13 | 'Included Types', 14 | 'Diatonic', 15 | 'Included Chords (Advanced)', 16 | ...expectedVoicingSettingsDescriptors, 17 | 'Number of chords', 18 | ], 19 | defaultSettings: { 20 | includeBass: true, 21 | includedRomanNumerals: ['I', 'ii', 'iii', 'IV', 'V', 'vi'], 22 | numberOfSegments: 1, 23 | includedPositions: [0, 1, 2], 24 | voiceLeading: 'CORRECT', 25 | ...defaultTonalExerciseSettings, 26 | }, 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/app/storage/migration-scripts/storage-migration-1.3.2.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../shared/testing-utility/testPureFunction'; 2 | import { migrationScript_1_3_2 } from './storage-migration-1.3.2'; 3 | 4 | describe('storage-migration-1.3.2', () => { 5 | testPureFunction(migrationScript_1_3_2.getNewData, [ 6 | { 7 | args: [{ 8 | exercise1: {}, 9 | }], 10 | returnValue: { 11 | exercise1: {}, 12 | }, 13 | }, 14 | { 15 | args: [{ 16 | exercise1: { 17 | exerciseSettings: {} 18 | }, 19 | }], 20 | returnValue: { 21 | exercise1: { 22 | exerciseSettings: {} 23 | }, 24 | }, 25 | }, 26 | { 27 | args: [{ 28 | exercise1: { 29 | exerciseSettings: { 30 | includedAnswers: ['♭II', 'i', 'iv', '#iv', 'viiᵒ'], 31 | }, 32 | }, 33 | }], 34 | returnValue: { 35 | exercise1: { 36 | exerciseSettings: { 37 | includedAnswers: ['bII', 'i', 'iv', '#iv', 'viidim'], 38 | }, 39 | }, 40 | }, 41 | }, 42 | ]) 43 | }); 44 | -------------------------------------------------------------------------------- /src/assets/shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/toSteadyPart.ts: -------------------------------------------------------------------------------- 1 | import { NoteEvent } from '../../../services/player.service'; 2 | import * as _ from 'lodash'; 3 | import { Subdivision } from 'tone/build/esm/core/type/Units'; 4 | import { NoteNumberOrName } from './notes/NoteNumberOrName'; 5 | import { toNoteName } from './notes/toNoteName'; 6 | import { 7 | OneOrMany, 8 | toArray 9 | } from '../../../shared/ts-utility/toArray'; 10 | 11 | /* 12 | * If got NoteEvent for input it doesn't change it 13 | * */ 14 | export function toSteadyPart(noteList: OneOrMany | NoteEvent>, noteDuration: Subdivision = '4n', velocity = 1): NoteEvent[] { 15 | let numberOfNotes: number = 0; 16 | return _.map(toArray(noteList), (frequencyOrEvent: OneOrMany | NoteEvent): NoteEvent => { 17 | if(typeof frequencyOrEvent === 'object' && !Array.isArray(frequencyOrEvent)) { 18 | return frequencyOrEvent; 19 | } 20 | return { 21 | notes: toArray(frequencyOrEvent).map(toNoteName), 22 | time: { 23 | [noteDuration]: numberOfNotes++, 24 | }, 25 | duration: noteDuration, 26 | velocity: velocity, 27 | }; 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/services/player.mock.service.ts: -------------------------------------------------------------------------------- 1 | import { PublicMembers } from '../shared/ts-utility/PublicMembers'; 2 | import { NoteEvent, PartToPlay, PlayerService } from './player.service'; 3 | import { Provider } from '@angular/core'; 4 | 5 | export class PlayerMockService implements PublicMembers { 6 | private _bpm: number = 120; 7 | readonly isReady = true; 8 | 9 | constructor() { 10 | } 11 | 12 | get bpm(): number { 13 | return this._bpm; 14 | } 15 | 16 | async init(): Promise { 17 | } 18 | 19 | async playMultipleParts(parts: PartToPlay[]): Promise { 20 | } 21 | 22 | async playPart(noteEventList: NoteEvent[]): Promise { 23 | } 24 | 25 | setBpm(bpm: number): void { 26 | this._bpm = bpm; 27 | } 28 | 29 | static providers: Provider[] = [ 30 | PlayerMockService, 31 | { 32 | provide: PlayerService, 33 | useExisting: PlayerMockService, 34 | } 35 | ] 36 | 37 | stopAndClearQueue(): void { 38 | } 39 | 40 | onAllPartsFinished(): Promise { 41 | return Promise.resolve(); 42 | } 43 | 44 | get lastPlayed(): PartToPlay[] | null { 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/shared/ts-utility/SubjectPromise.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | export class SubjectPromise implements Promise { 4 | resolve: (value: T | PromiseLike) => void = _.noop; 5 | reject: (reason?: any) => void = _.noop; 6 | readonly [Symbol.toStringTag]: string; 7 | private readonly _promise = new Promise((resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void) => { 8 | this.resolve = resolve; 9 | this.reject = reject; 10 | }); 11 | 12 | catch(onrejected?: ((reason: any) => (PromiseLike | TResult)) | undefined | null): Promise { 13 | return this._promise.catch(onrejected); 14 | } 15 | 16 | finally(onfinally?: (() => void) | undefined | null): Promise { 17 | return this._promise.finally(onfinally); 18 | } 19 | 20 | then( 21 | onfulfilled?: ((value: T) => (PromiseLike | TResult1)) | undefined | null, 22 | onrejected?: ((reason: any) => (PromiseLike | TResult2)) | undefined | null, 23 | ): Promise { 24 | return this._promise.then(onfulfilled, onrejected); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; 3 | import { SandboxComponent } from './sandbox/sandbox.component'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: 'home', 8 | loadChildren: () => import('./home/home.module').then( m => m.HomePageModule) 9 | }, 10 | { 11 | path: 'about', 12 | loadChildren: () => import('./about/about.module').then( m => m.AboutModule) 13 | }, 14 | { 15 | path: 'message/:id', 16 | loadChildren: () => import('./view-message/view-message.module').then( m => m.ViewMessagePageModule) 17 | }, 18 | { 19 | path: 'exercise/:id', 20 | loadChildren: () => import('./exercise/exercise.module').then( m => m.ExerciseModule) 21 | }, 22 | { 23 | path: 'sandbox', 24 | component: SandboxComponent, 25 | }, 26 | { 27 | path: '', 28 | redirectTo: 'home', 29 | pathMatch: 'full' 30 | }, 31 | ]; 32 | 33 | @NgModule({ 34 | imports: [ 35 | RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) 36 | ], 37 | exports: [RouterModule] 38 | }) 39 | export class AppRoutingModule { } 40 | -------------------------------------------------------------------------------- /src/app/home/home.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OpenEar 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/notes/toNoteTypeNumber.spec.ts: -------------------------------------------------------------------------------- 1 | import { NoteType } from './NoteType'; 2 | import { 3 | toNoteTypeNumber, 4 | toNoteTypeName 5 | } from './toNoteTypeNumber'; 6 | 7 | describe('toNoteTypeNumber', function () { 8 | const testCases: [NoteType | number, number][] = [ 9 | ['C', 0], 10 | ['C#', 1], 11 | ['Db', 1], 12 | ['G', 7], 13 | ['Bb', 10], 14 | ['B', 11], 15 | [2, 2], 16 | [11, 11], 17 | ]; 18 | testCases.forEach(([noteTypeOrNumber, noteTypeNumber]) => { 19 | it(`The note type number of ${noteTypeOrNumber} is ${noteTypeNumber}`, () => { 20 | expect(toNoteTypeNumber(noteTypeNumber)).toEqual(noteTypeNumber); 21 | }); 22 | }); 23 | }); 24 | 25 | describe('toNoteTypeName', function () { 26 | const testCases: [NoteType | number, NoteType][] = [ 27 | [0, 'C'], 28 | [1, 'C#'], 29 | [7, 'G'], 30 | [10, 'A#'], 31 | [11, 'B'], 32 | ['D#', 'D#'], 33 | ['B', 'B'], 34 | ]; 35 | testCases.forEach(([noteTypeOrNumber, noteType]) => { 36 | it(`The note type of ${noteTypeOrNumber} is ${noteType}`, () => { 37 | expect(toNoteTypeName(noteTypeOrNumber)).toEqual(noteType); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/app/services/exercise-settings-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ExerciseSettingsData } from '../exercise/utility'; 3 | import { StorageService } from '../storage/storage.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class ExerciseSettingsDataService { 9 | private readonly _exerciseSettingsKey: string = 'exerciseSettings'; 10 | 11 | constructor(private _storageService: StorageService) { 12 | } 13 | 14 | async saveExerciseSettings(exerciseId: string, settings: Partial): Promise { 15 | const currentExercisesSettings: { 16 | [exerciseKey: string]: ExerciseSettingsData 17 | } = await this._storageService.get(this._exerciseSettingsKey) || {}; 18 | currentExercisesSettings[exerciseId] = { 19 | ...currentExercisesSettings[exerciseId], 20 | ...settings 21 | }; 22 | await this._storageService.set(this._exerciseSettingsKey, currentExercisesSettings); 23 | } 24 | 25 | async getExerciseSettings(exerciseId: string): Promise | undefined> { 26 | return (await this._storageService.get(this._exerciseSettingsKey))?.[exerciseId]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/components/exercise-settings.page/components/included-answers/included-answers.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Exercise } from '../../../../../Exercise'; 3 | import { BaseControlValueAccessorComponent, getNgValueAccessorProvider } from '../../../../../../shared/ts-utility'; 4 | 5 | @Component({ 6 | selector: 'app-included-answers', 7 | templateUrl: './included-answers.component.html', 8 | styleUrls: ['./included-answers.component.scss'], 9 | providers: [ 10 | getNgValueAccessorProvider(IncludedAnswersComponent), 11 | ] 12 | }) 13 | export class IncludedAnswersComponent extends BaseControlValueAccessorComponent { 14 | @Input() 15 | answerList: Exercise.AnswerList; 16 | 17 | async toggleInclusion(answer: GAnswer): Promise { 18 | const currentValue: ReadonlyArray = await this.getCurrentValuePromise(); 19 | if (currentValue.includes(answer)) { 20 | this.setViewValue(currentValue.filter(value => value !== answer)); 21 | } else { 22 | this.setViewValue([ 23 | ...currentValue, 24 | answer, 25 | ]); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/scale-degrees/ScaleDegrees.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPureFunction } from '../../../../shared/testing-utility/testPureFunction'; 2 | import { 3 | getNoteFromScaleDegree, 4 | getScaleDegreeFromNote, 5 | } from './ScaleDegrees'; 6 | 7 | describe('getNoteFromScaleDegree', () => { 8 | testPureFunction(getNoteFromScaleDegree, [ 9 | { 10 | args: ['C', '1'], 11 | returnValue: 'C4', 12 | }, 13 | { 14 | args: ['C', '2'], 15 | returnValue: 'D4', 16 | }, 17 | { 18 | args: ['C', '#4'], 19 | returnValue: 'F#4', 20 | }, 21 | { 22 | args: ['Eb', '3'], 23 | returnValue: 'G4', 24 | }, 25 | { 26 | args: ['F', 'b7'], 27 | returnValue: 'D#4', // currently, sharps are always returned, but preferably we'll return Eb here 28 | }, 29 | ]) 30 | }); 31 | 32 | describe('getScaleDegreeFromNote', function() { 33 | testPureFunction(getScaleDegreeFromNote, [ 34 | { 35 | args: ['C', 'D4'], 36 | returnValue: '2', 37 | }, 38 | { 39 | args: ['C', 'Eb4'], 40 | returnValue: 'b3', 41 | }, 42 | { 43 | args: ['D', 'Eb4'], 44 | returnValue: 'b2', 45 | } 46 | ]) 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/shared/testing-utility/TestingUtility.ts: -------------------------------------------------------------------------------- 1 | export class TestingUtility { 2 | static getButtonByIcon(iconName: string): HTMLElement { 3 | const iconButton = document.querySelector(`ion-button ion-icon[name="${iconName}"]`); 4 | if (!iconButton) { 5 | throw new Error(`Could not find button with icon ${iconName}`); 6 | } 7 | return iconButton; 8 | } 9 | 10 | static getElementByText(text: string, selector: string): HTMLElement { 11 | const element = Array.from(document.querySelectorAll(selector)).find((element: HTMLElement | null) => { 12 | return element?.innerText.toLowerCase() === text.toLowerCase(); 13 | }); 14 | if (!element) { 15 | throw new Error(`Could not find ${selector} element with text ${text}`); 16 | } 17 | return element; 18 | } 19 | 20 | static getButtonByText(text: string): HTMLElement { 21 | return TestingUtility.getElementByText(text, 'ion-button'); 22 | } 23 | 24 | static isDisabled(button: HTMLElement): boolean { 25 | if (button.tagName.toLowerCase() === 'ion-button') { 26 | return button.getAttribute('ng-reflect-disabled') === 'true'; 27 | } 28 | return button.hasAttribute('disabled'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * App Global CSS 3 | * ---------------------------------------------------------------------------- 4 | * Put style rules here that you want to apply globally. These styles are for 5 | * the entire app and not just one component. Additionally, this file can be 6 | * used as an entry point to import other CSS/Sass files to be included in the 7 | * output CSS. 8 | * For more information on global stylesheets, visit the documentation: 9 | * https://ionicframework.com/docs/layout/global-stylesheets 10 | */ 11 | 12 | /* Core CSS required for Ionic components to work properly */ 13 | @import "~@ionic/angular/css/core.css"; 14 | 15 | /* Basic CSS for apps built with Ionic */ 16 | @import "~@ionic/angular/css/normalize.css"; 17 | @import "~@ionic/angular/css/structure.css"; 18 | @import "~@ionic/angular/css/typography.css"; 19 | @import '~@ionic/angular/css/display.css'; 20 | 21 | /* Optional CSS utils that can be commented out */ 22 | @import "~@ionic/angular/css/padding.css"; 23 | @import "~@ionic/angular/css/float-elements.css"; 24 | @import "~@ionic/angular/css/text-alignment.css"; 25 | @import "~@ionic/angular/css/text-transformation.css"; 26 | @import "~@ionic/angular/css/flex-utils.css"; 27 | 28 | @import "overrides/index"; 29 | -------------------------------------------------------------------------------- /src/app/exercise/exercise.page/utility/getCurrentAnswersLayout.ts: -------------------------------------------------------------------------------- 1 | import { CurrentAnswer } from '../state/exercise-state.service'; 2 | import * as _ from 'lodash'; 3 | 4 | // todo: it's probably better to group by voice, as later we might want to give each answer a different duration for different contrapuntal scenarios 5 | export function getCurrentAnswersLayout( 6 | currentAnswers: CurrentAnswer[], 7 | ) { 8 | /** 9 | * Using "playAfter" to calculate the desired layout of the answers, to reflect musical timing 10 | * each array are events happening in the same time 11 | */ 12 | const currentAnswersLayout: (CurrentAnswer & { 13 | index: number; 14 | })[][] = []; 15 | 16 | let columnIndex = 0; // the visual horizontal position of the answer 17 | 18 | currentAnswers.forEach((currentAnswer, answerIndex) => { 19 | if (!_.isNil(currentAnswer.playAfter)) { 20 | columnIndex = currentAnswer.playAfter; 21 | } 22 | 23 | if (!currentAnswersLayout[columnIndex]) { 24 | currentAnswersLayout[columnIndex] = []; 25 | } 26 | 27 | currentAnswersLayout[columnIndex].push({ 28 | ...currentAnswer, 29 | index: answerIndex, 30 | }); 31 | columnIndex++; 32 | }); 33 | return currentAnswersLayout; 34 | } 35 | -------------------------------------------------------------------------------- /src/app/exercise/utility/music/keys/isInKey.ts: -------------------------------------------------------------------------------- 1 | import { NoteNumberOrName } from '../notes/NoteNumberOrName'; 2 | import { Key } from './Key'; 3 | import { Note } from 'tone/Tone/core/type/NoteUnits'; 4 | import { 5 | toNoteName, 6 | toNoteNumber 7 | } from '../notes/toNoteName'; 8 | import { transpose } from '../transpose'; 9 | import { getDistanceOfKeys } from './getDistanceOfKeys'; 10 | import { mod } from '../../../../shared/ts-utility/mod'; 11 | import { Interval } from '../intervals/Interval'; 12 | 13 | const CMajorFirstOctave: Note[] = ['C1', 'D1', 'E1', 'F1', 'G1', 'A1', 'B1']; 14 | 15 | export function isInKey(note: NoteNumberOrName, key: Key) { 16 | function transposeToFirstOctave(note: NoteNumberOrName): Note { 17 | return toNoteName(mod(toNoteNumber(note) - toNoteNumber('C1'), Interval.Octave) + toNoteNumber('C1')); 18 | } 19 | const noteTransposedToFirstOctave: Note = transposeToFirstOctave(note); 20 | const distanceOfKeyFromC: number = mod(getDistanceOfKeys(key, 'C'), Interval.Octave); 21 | const scaleOfKey: Note[] = transpose(CMajorFirstOctave, distanceOfKeyFromC); 22 | const scaleOfKeyInFirstOctave = scaleOfKey.map(transposeToFirstOctave); 23 | return scaleOfKeyInFirstOctave.map(toNoteNumber).includes(toNoteNumber(noteTransposedToFirstOctave)); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { 3 | TestBed, 4 | waitForAsync, 5 | } from '@angular/core/testing'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { IonicTestingModule } from './shared/ionic-testing/ionic-testing.module'; 9 | import { ReleaseNotesTestingModule } from './release-notes/release-notes.testing.module'; 10 | import { StorageTestingModule } from './storage/storage.testing.module'; 11 | import { StorageMigrationService } from './storage/storage-migration.service'; 12 | 13 | describe('AppComponent', () => { 14 | beforeEach(waitForAsync(() => { 15 | TestBed.configureTestingModule({ 16 | imports: [ 17 | IonicTestingModule, 18 | ReleaseNotesTestingModule, 19 | StorageTestingModule, 20 | ], 21 | declarations: [ 22 | AppComponent, 23 | ], 24 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 25 | }).compileComponents(); 26 | })); 27 | 28 | it('should call runMigrationScripts on creation', () => { 29 | const spy = spyOn(TestBed.inject(StorageMigrationService), 'runMigrationScripts'); 30 | const fixture = TestBed.createComponent(AppComponent); 31 | expect(fixture).toBeTruthy(); 32 | expect(spy).toHaveBeenCalledOnceWith(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/view-message/view-message.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

14 | {{ message.fromName }} 15 | 16 | {{ message.date }} 17 | 18 |

19 |

To: Me

20 |
21 |
22 | 23 |
24 |

{{ message.subject }}

25 |

26 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 27 |

28 |
29 |
30 | --------------------------------------------------------------------------------