├── ios ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist └── .gitignore ├── android ├── gradle.properties ├── app │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21 │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── values │ │ │ │ │ └── styles.xml │ │ │ │ └── values-night │ │ │ │ │ └── styles.xml │ │ │ ├── java │ │ │ │ └── com │ │ │ │ │ └── sillyapps │ │ │ │ │ └── voice_changer │ │ │ │ │ └── MainActivity.java │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle └── build.gradle ├── lib ├── domain │ ├── common │ │ ├── exception │ │ │ ├── exception_utils.dart │ │ │ └── failure.dart │ │ ├── extensions │ │ │ ├── double_extensions.dart │ │ │ ├── string_extensions.dart │ │ │ ├── datetime_extensions.dart │ │ │ ├── directory_extensions.dart │ │ │ └── file_extensions.dart │ │ └── service │ │ │ ├── permission_handler_service_impl.dart │ │ │ ├── permission_handler_service.dart │ │ │ └── filesystem_service.dart │ ├── recording_details │ │ ├── recording_details_service.dart │ │ └── recording_details_service_impl.dart │ ├── sound_changer │ │ ├── sound_changer_service.dart │ │ └── sound_changer_service_impl.dart │ ├── player │ │ ├── player_service.dart │ │ └── player_service_impl.dart │ └── recorder │ │ ├── recorder_service.dart │ │ └── recorder_service_impl.dart ├── presentation │ ├── recordings_screen │ │ ├── bloc │ │ │ ├── recordings_bloc │ │ │ │ ├── recordings_bloc_event.dart │ │ │ │ ├── recordings_bloc_state.dart │ │ │ │ └── recordings_bloc.dart │ │ │ └── player_bloc │ │ │ │ ├── player_bloc_event.dart │ │ │ │ ├── player_bloc_state.dart │ │ │ │ └── player_bloc.dart │ │ └── widget │ │ │ ├── recordings_screen.dart │ │ │ ├── recordings_screen_components.dart │ │ │ ├── recordings_listview.dart │ │ │ └── recording_tile_contents.dart │ ├── styles │ │ └── styles.dart │ ├── common │ │ ├── filled_circle.dart │ │ ├── filled_rectangle.dart │ │ ├── error_widget.dart │ │ └── loading_widget.dart │ ├── recorder_screen │ │ ├── bloc │ │ │ ├── permission_bloc │ │ │ │ ├── permission_bloc_state.dart │ │ │ │ ├── permission_bloc_event.dart │ │ │ │ └── permission_bloc.dart │ │ │ └── recorder_bloc │ │ │ │ ├── recorder_bloc_event.dart │ │ │ │ ├── recorder_bloc_state.dart │ │ │ │ └── recorder_bloc.dart │ │ └── widget │ │ │ ├── recordings_button.dart │ │ │ ├── recorder_screen.dart │ │ │ ├── recorder_icon_widget.dart │ │ │ ├── recorder_screen_components.dart │ │ │ ├── record_button.dart │ │ │ └── stop_button.dart │ └── sound_changer_screen │ │ ├── widget │ │ ├── sound_changer_screen_components.dart │ │ ├── sound_changer_screen.dart │ │ └── sound_changer_options.dart │ │ └── bloc │ │ ├── sound_changer_bloc_event.dart │ │ ├── sound_changer_bloc_state.dart │ │ └── sound_changer_bloc.dart ├── configuration │ ├── service_locator.dart │ └── service_locator.config.dart └── main.dart ├── screenshots ├── Screenshot from 2021-10-14 18-53-27.png ├── Screenshot from 2021-10-14 18-53-41.png ├── Screenshot from 2021-10-14 18-53-52.png ├── Screenshot from 2021-10-14 18-54-20.png └── Screenshot from 2021-10-14 18-54-28.png ├── .metadata ├── .gitignore ├── README.md ├── analysis_options.yaml └── pubspec.yaml /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /lib/domain/common/exception/exception_utils.dart: -------------------------------------------------------------------------------- 1 | Never crashWithMessage(String message) { 2 | throw Exception('exception->$message'); 3 | // exit(1); 4 | } 5 | -------------------------------------------------------------------------------- /lib/domain/common/extensions/double_extensions.dart: -------------------------------------------------------------------------------- 1 | extension DoubleExtension on double { 2 | double toPrecision(int n) => double.parse(toStringAsFixed(n)); 3 | } -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /screenshots/Screenshot from 2021-10-14 18-53-27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/screenshots/Screenshot from 2021-10-14 18-53-27.png -------------------------------------------------------------------------------- /screenshots/Screenshot from 2021-10-14 18-53-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/screenshots/Screenshot from 2021-10-14 18-53-41.png -------------------------------------------------------------------------------- /screenshots/Screenshot from 2021-10-14 18-53-52.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/screenshots/Screenshot from 2021-10-14 18-53-52.png -------------------------------------------------------------------------------- /screenshots/Screenshot from 2021-10-14 18-54-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/screenshots/Screenshot from 2021-10-14 18-54-20.png -------------------------------------------------------------------------------- /screenshots/Screenshot from 2021-10-14 18-54-28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/screenshots/Screenshot from 2021-10-14 18-54-28.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsjadSiddiqui/Voice-Changer/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/sillyapps/voice_changer/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.sillyapps.voice_changer; 2 | 3 | import io.flutter.embedding.android.FlutterActivity; 4 | 5 | public class MainActivity extends FlutterActivity { 6 | } 7 | -------------------------------------------------------------------------------- /lib/domain/common/extensions/string_extensions.dart: -------------------------------------------------------------------------------- 1 | extension StringExtension on String { 2 | bool isAlphaNumericWithUnderscores() => 3 | RegExp(r'^[a-zA-Z0-9_]*$').hasMatch(this); 4 | 5 | bool isRealNumber() => RegExp(r'(^\d*\.?\d*)').hasMatch(this); 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip 7 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: ffb2ecea5223acdd139a5039be2f9c796962833d 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/recordings_bloc/recordings_bloc_event.dart: -------------------------------------------------------------------------------- 1 | part of 'recordings_bloc.dart'; 2 | 3 | @freezed 4 | class RecordingsBlocEvent with _$RecordingsBlocEvent { 5 | const factory RecordingsBlocEvent.refresh() = _Refresh; 6 | 7 | const factory RecordingsBlocEvent.deleteRecording(String path) = 8 | _DeleteRecordingEvent; 9 | } 10 | -------------------------------------------------------------------------------- /lib/domain/common/extensions/datetime_extensions.dart: -------------------------------------------------------------------------------- 1 | extension DateTimeExtension on DateTime { 2 | ///A string representation of this DateTime which is suitable for a file path String 3 | String toPathSuitableString() => 4 | toIso8601String().split('.')[0].replaceAllMapped(RegExp(r'(:|-)'), 5 | (_) => '_'); //replace special characters with underscores 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /lib/presentation/styles/styles.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | //Text styles 4 | TextStyle get verySmallText => const TextStyle(fontSize: 10); 5 | 6 | TextStyle get smallText => const TextStyle(fontSize: 15); 7 | 8 | TextStyle get mediumText => const TextStyle(fontSize: 20); 9 | 10 | TextStyle get largeText => const TextStyle(fontSize: 30); 11 | 12 | TextStyle get veryLargeText => const TextStyle(fontSize: 40); 13 | 14 | //App colors 15 | ColorScheme get colorScheme => const ColorScheme.light(); 16 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/presentation/common/filled_circle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FilledCircle extends StatelessWidget { 4 | final double radius; 5 | final Color color; 6 | 7 | const FilledCircle({ 8 | Key? key, 9 | required this.radius, 10 | required this.color, 11 | }) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) => ClipOval( 15 | child: Container( 16 | width: radius, 17 | height: radius, 18 | color: color, 19 | ), 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/permission_bloc/permission_bloc_state.dart: -------------------------------------------------------------------------------- 1 | part of 'permission_bloc.dart'; 2 | 3 | @freezed 4 | class PermissionBlocState with _$PermissionBlocState { 5 | const PermissionBlocState._(); 6 | 7 | const factory PermissionBlocState({ 8 | @Default(false) bool isMicrophonePermissionGranted, 9 | }) = _PermissionBlocState; 10 | 11 | @override 12 | String toString() { 13 | return '\nPermissionBlocState{\n' 14 | 'isMicrophonePermissionGranted:$isMicrophonePermissionGranted,\n' 15 | '}\n'; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/configuration/service_locator.dart: -------------------------------------------------------------------------------- 1 | import 'package:voice_changer/configuration/service_locator.config.dart'; 2 | import 'package:get_it/get_it.dart'; 3 | import 'package:injectable/injectable.dart'; 4 | import 'package:logger/logger.dart'; 5 | 6 | final GetIt serviceLocator = GetIt.instance; 7 | 8 | @InjectableInit() 9 | void configureDependencies() => $initGetIt(serviceLocator); 10 | 11 | @module 12 | abstract class RegisterModule { 13 | Logger logger(@factoryParam Level? logLevel,@factoryParam LogPrinter? printer) => Logger(level: logLevel,printer: printer); 14 | } 15 | -------------------------------------------------------------------------------- /lib/presentation/common/filled_rectangle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FilledRectangle extends StatelessWidget { 4 | final double width; 5 | final double height; 6 | final Color color; 7 | 8 | const FilledRectangle({ 9 | Key? key, 10 | required this.width, 11 | required this.height, 12 | required this.color, 13 | }) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Container( 18 | width: width, 19 | height: height, 20 | color: color, 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:7.0.2' 9 | } 10 | } 11 | 12 | allprojects { 13 | repositories { 14 | google() 15 | mavenCentral() 16 | } 17 | } 18 | 19 | rootProject.buildDir = '../build' 20 | subprojects { 21 | project.buildDir = "${rootProject.buildDir}/${project.name}" 22 | project.evaluationDependsOn(':app') 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /lib/presentation/common/error_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:voice_changer/presentation/styles/styles.dart'; 3 | 4 | class ErrorWidget extends StatelessWidget { 5 | final String message; 6 | 7 | const ErrorWidget(this.message, {Key? key}) : super(key: key); 8 | 9 | @override 10 | Widget build(BuildContext context) => Scaffold( 11 | body: Center( 12 | child: Text( 13 | 'Error: $message', 14 | style: mediumText, 15 | textAlign: TextAlign.center, 16 | ), 17 | ), 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/widget/sound_changer_screen_components.dart: -------------------------------------------------------------------------------- 1 | part of 'sound_changer_screen.dart'; 2 | 3 | class _SoundChangerScreenComponents extends StatelessWidget { 4 | const _SoundChangerScreenComponents({Key? key}) : super(key: key); 5 | 6 | @override 7 | Widget build(BuildContext context) => Scaffold( 8 | resizeToAvoidBottomInset: false, 9 | appBar: AppBar( 10 | title: Text( 11 | 'Sound Editor', 12 | style: mediumText, 13 | ), 14 | centerTitle: true, 15 | ), 16 | body: const _SoundChangerOptions(), 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/ephemeral/ 22 | Flutter/app.flx 23 | Flutter/app.zip 24 | Flutter/flutter_assets/ 25 | Flutter/flutter_export_environment.sh 26 | ServiceDefinitions.json 27 | Runner/GeneratedPluginRegistrant.* 28 | 29 | # Exceptions to above rules. 30 | !default.mode1v3 31 | !default.mode2v3 32 | !default.pbxuser 33 | !default.perspectivev3 34 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/player_bloc/player_bloc_event.dart: -------------------------------------------------------------------------------- 1 | part of 'player_bloc.dart'; 2 | 3 | @freezed 4 | class PlayerBlocEvent with _$PlayerBlocEvent { 5 | const factory PlayerBlocEvent.init() = _Init; 6 | 7 | const factory PlayerBlocEvent.start({ 8 | required RecordingDetails recording, 9 | }) = _Start; 10 | 11 | const factory PlayerBlocEvent.pause() = _Pause; 12 | 13 | const factory PlayerBlocEvent.resume() = _Resume; 14 | 15 | const factory PlayerBlocEvent.stop({ 16 | Function? onDone, 17 | }) = _Stop; 18 | 19 | const factory PlayerBlocEvent.seekToPosition(Duration position) = 20 | _SeekToPosition; 21 | 22 | ///This event is fired when the app goes inactive 23 | const factory PlayerBlocEvent.appGoInactive() = _AppGoInactiveEvent; 24 | } -------------------------------------------------------------------------------- /lib/domain/common/extensions/directory_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 4 | 5 | extension DirectoryExtension on Directory { 6 | ///Returns the files in this directory 7 | ///- [extension] : if provided then the only the files with this extension will 8 | ///be returned 9 | List getFiles({String? extension}) { 10 | List fileSystemEntities = listSync(recursive: false) 11 | ..removeWhere((element) => element is! File); 12 | if (extension != null) { 13 | fileSystemEntities.removeWhere((element) => 14 | FileExtension.getExtension((element as File).path) != extension); 15 | } 16 | return fileSystemEntities.map((element) => File(element.path)).toList(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/permission_bloc/permission_bloc_event.dart: -------------------------------------------------------------------------------- 1 | part of 'permission_bloc.dart'; 2 | 3 | @freezed 4 | class PermissionBlocEvent with _$PermissionBlocEvent { 5 | ///[onGranted] : a callback to execute if the permission was granted 6 | const factory PermissionBlocEvent.requestMicrophonePermission({ 7 | Function? onGranted, 8 | Function? onDenied, 9 | Function? onPermanentlyDenied, 10 | }) = _RequestMicrophonePermissionEvent; 11 | 12 | ///[onGranted] : a callback to execute if the permission was granted 13 | const factory PermissionBlocEvent.checkMicrophonePermission({ 14 | Function? onGranted, 15 | Function? onDenied, 16 | }) = _CheckMicrophonePermissionEvent; 17 | 18 | const factory PermissionBlocEvent.openSettingsApp() = _OpenSettingsAppEvent; 19 | } 20 | -------------------------------------------------------------------------------- /lib/presentation/common/loading_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:voice_changer/presentation/styles/styles.dart'; 3 | 4 | class LoadingWidget extends StatelessWidget { 5 | const LoadingWidget({Key? key}) : super(key: key); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return Scaffold( 10 | body: Center( 11 | child: Column( 12 | mainAxisAlignment: MainAxisAlignment.center, 13 | children: [ 14 | ConstrainedBox( 15 | constraints: BoxConstraints.tight(const Size.square(80)), 16 | child: const CircularProgressIndicator(), 17 | ), 18 | const SizedBox(height: 10), 19 | Text('Loading...', style: largeText), 20 | ], 21 | ), 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/recordings_bloc/recordings_bloc_state.dart: -------------------------------------------------------------------------------- 1 | part of 'recordings_bloc.dart'; 2 | 3 | @freezed 4 | class RecordingsBlocState with _$RecordingsBlocState { 5 | const RecordingsBlocState._(); 6 | 7 | const factory RecordingsBlocState({ 8 | @Default([]) List recordings, 9 | @Default(false) bool isInitialized, 10 | @Default(false) bool isProcessing, 11 | @Default(false) bool isError, 12 | String? errorMessage, 13 | }) = _RecordingsBlocState; 14 | 15 | @override 16 | String toString() { 17 | return '\nRecordingsBlocState{\n' 18 | 'recordings: $recordings\n' 19 | 'isInitialized: $isInitialized,\n' 20 | 'isProcessing: $isProcessing,\n' 21 | 'isError: $isError,\n' 22 | 'errorMessage: $errorMessage,\n' 23 | '}\n'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 9.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/player_bloc/player_bloc_state.dart: -------------------------------------------------------------------------------- 1 | part of 'player_bloc.dart'; 2 | 3 | ///[recording] : the recording being played 4 | @freezed 5 | class PlayerBlocState with _$PlayerBlocState { 6 | const PlayerBlocState._(); 7 | 8 | const factory PlayerBlocState({ 9 | @Default(PlayerState.uninitialized()) PlayerState playerState, 10 | @Default(Duration.zero) Duration position, 11 | RecordingDetails? recording, 12 | @Default(false) bool isProcessing, 13 | @Default(false) bool isError, 14 | String? errorMessage, 15 | }) = _PlayerBlocState; 16 | 17 | @override 18 | String toString() { 19 | return '\nPlayerBlocState{\n' 20 | 'playerState: $playerState\n' 21 | 'position: $position\n' 22 | 'recording: $recording,\n' 23 | 'isProcessing: $isProcessing,\n' 24 | 'isError: $isError,\n' 25 | 'errorMessage: $errorMessage,\n' 26 | '}\n'; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | /gradle.properties 48 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/recorder_bloc/recorder_bloc_event.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_bloc.dart'; 2 | 3 | @freezed 4 | class RecorderBlocEvent with _$RecorderBlocEvent { 5 | const factory RecorderBlocEvent.init() = _InitEvent; 6 | 7 | ///This event is fired when record button is pressed 8 | const factory RecorderBlocEvent.startRecording() = _StartRecordingEvent; 9 | 10 | ///This event is fired when stop button is pressed 11 | const factory RecorderBlocEvent.stopRecording() = _StopRecordingEvent; 12 | 13 | ///This event is fired when the save button is pressed after stopping the recording 14 | const factory RecorderBlocEvent.saveRecording({ 15 | required String newRecordingFileName, 16 | }) = _SaveRecordingEvent; 17 | 18 | const factory RecorderBlocEvent.deleteRecording() = _DeleteRecordingEvent; 19 | 20 | ///This event is fired when the app goes inactive 21 | const factory RecorderBlocEvent.appGoInactive() = _AppGoInactiveEvent; 22 | } 23 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/recorder_bloc/recorder_bloc_state.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_bloc.dart'; 2 | 3 | @freezed 4 | class RecorderBlocState with _$RecorderBlocState { 5 | const RecorderBlocState._(); 6 | 7 | ///[volume] : The volume of the current recording, if there is any 8 | ///[duration] : The duration of the current recording, if there is any. 9 | ///[recorderState] : The current state of the recorder. 10 | const factory RecorderBlocState({ 11 | @Default(RecorderState.uninitialized()) RecorderState recorderState, 12 | @Default(Duration.zero) Duration duration, 13 | @Default(0) double volume, 14 | RecordingDetails? recording, 15 | @Default(false) bool isError, 16 | String? errorMessage, 17 | }) = _RecorderBlocState; 18 | 19 | @override 20 | String toString() { 21 | return '\nRecorderBlocState{\n' 22 | 'recorderState: $recorderState,\n' 23 | 'duration: $duration,\n' 24 | 'volume: $volume,\n' 25 | 'recording: $recording,\n' 26 | 'isError: $isError,\n' 27 | 'errorMessage: $errorMessage,\n' 28 | '}\n'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # voice_changer 2 | 3 | Voice Changer Application 4 | 5 | ## Getting Started 6 | 7 | This is an application that records, plays, and changes the properties of sound (like pitch and echo...). 8 | 9 | It uses Flutter_FFMPEG to do the voice editing. 10 | 11 | Just clone the repo and run: 12 | 13 | - flutter clean 14 | - flutter pub get 15 | - flutter pub run build_runner build --delete-conflicting-outputs 16 | - flutter run 17 | 18 | 19 | 20 | Here are some screenshots: 21 | 22 | - ![alt text](https://github.com/Haidar0096/Voice-Changer/blob/master/screenshots/Screenshot%20from%202021-10-14%2018-53-27.png?raw=true) 23 | - ![alt text](https://github.com/Haidar0096/Voice-Changer/blob/master/screenshots/Screenshot%20from%202021-10-14%2018-53-41.png?raw=true) 24 | - ![alt text](https://github.com/Haidar0096/Voice-Changer/blob/master/screenshots/Screenshot%20from%202021-10-14%2018-53-52.png?raw=true) 25 | - ![alt text](https://github.com/Haidar0096/Voice-Changer/blob/master/screenshots/Screenshot%20from%202021-10-14%2018-54-20.png?raw=true) 26 | - ![alt text](https://github.com/Haidar0096/Voice-Changer/blob/master/screenshots/Screenshot%20from%202021-10-14%2018-54-28.png?raw=true) 27 | -------------------------------------------------------------------------------- /lib/domain/recording_details/recording_details_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:voice_changer/domain/common/exception/failure.dart'; 5 | 6 | abstract class RecordingDetailsService { 7 | ///Returns the details of the provided recording fiel 8 | Future> getRecordingDetails( 9 | File recordingFile); 10 | } 11 | 12 | class RecordingDetails { 13 | final String name; 14 | final String path; 15 | 16 | ///The duration of the recording, or null if unavailable 17 | final Duration? duration; 18 | 19 | RecordingDetails({ 20 | required this.name, 21 | required this.path, 22 | required this.duration, 23 | }); 24 | 25 | @override 26 | String toString() { 27 | return 'RecordingDetails{name: $name, path: $path, duration: $duration}'; 28 | } 29 | 30 | @override 31 | bool operator ==(Object other) => 32 | identical(this, other) || 33 | other is RecordingDetails && 34 | runtimeType == other.runtimeType && 35 | name == other.name && 36 | path == other.path && 37 | duration == other.duration; 38 | 39 | @override 40 | int get hashCode => name.hashCode ^ path.hashCode ^ duration.hashCode; 41 | } 42 | -------------------------------------------------------------------------------- /lib/domain/common/extensions/file_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:voice_changer/domain/common/extensions/string_extensions.dart'; 4 | 5 | extension FileExtension on File { 6 | ///Returns the name of the file.For example if file path was dirA/dirB/dirC/file_name.extension then this method returns file_name 7 | /// It can also return it with the extension. Note that the file name must be of the form file_name.extension for this method to give the 8 | /// expected result 9 | static String getName(String path, {bool withExtension = false}) { 10 | if (withExtension) return path.split(Platform.pathSeparator).last; 11 | return path.split(Platform.pathSeparator).last.split('.').first; 12 | } 13 | 14 | ///The file name must be of the form file_name.extension for this method to give the 15 | /// expected result. For example file_name.extension would return extension 16 | static String getExtension(String path) => path.split('.').last; 17 | 18 | ///The file name must be alphanumeric with underscores only 19 | static String? isValidFileName(String text) => 20 | text.isAlphaNumericWithUnderscores() && text.isNotEmpty 21 | ? null 22 | : 'file name must contain only alphanumeric/underscore characters' 23 | ', and must not be empty'; 24 | } 25 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/recordings_button.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_screen.dart'; 2 | 3 | class _RecordingsButton extends StatelessWidget { 4 | @override 5 | Widget build(BuildContext context) { 6 | final mq = MediaQuery.of(context); 7 | final width = mq.size.width; 8 | final containerSide = width / 5; 9 | 10 | return SizedBox( 11 | width: containerSide, 12 | height: containerSide, 13 | child: BlocBuilder( 14 | builder: (context, recorderBlocState) { 15 | return GestureDetector( 16 | onTap: !recorderBlocState.recorderState.isRecording 17 | ? () => 18 | Navigator.of(context).pushNamed(RecordingsScreen.routeName) 19 | : null, 20 | child: Column( 21 | mainAxisAlignment: MainAxisAlignment.end, 22 | children: [ 23 | Icon( 24 | Icons.menu, 25 | size: containerSide / 2.2, 26 | color: recorderBlocState.recorderState.isRecording 27 | ? Theme.of(context).disabledColor 28 | : Theme.of(context).colorScheme.secondary, 29 | ), 30 | Text('recordings', style: smallText), 31 | ], 32 | ), 33 | ); 34 | }, 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:voice_changer/configuration/service_locator.dart' 4 | as service_locator; 5 | import 'package:voice_changer/presentation/recorder_screen/widget/recorder_screen.dart'; 6 | import 'package:voice_changer/presentation/recordings_screen/widget/recordings_screen.dart'; 7 | import 'package:voice_changer/presentation/sound_changer_screen/widget/sound_changer_screen.dart'; 8 | import 'package:voice_changer/presentation/styles/styles.dart' as styles; 9 | 10 | void main() { 11 | WidgetsFlutterBinding.ensureInitialized(); 12 | service_locator.configureDependencies(); 13 | runApp(const AppWidget()); 14 | } 15 | 16 | class AppWidget extends StatelessWidget { 17 | const AppWidget({Key? key}) : super(key: key); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | //set mode to portrait only 22 | SystemChrome.setPreferredOrientations([ 23 | DeviceOrientation.portraitUp, 24 | ]); 25 | return MaterialApp( 26 | routes: _routes(), 27 | theme: ThemeData( 28 | colorScheme: styles.colorScheme, 29 | ), 30 | ); 31 | } 32 | 33 | Map _routes() => { 34 | '/': (context) => const RecorderScreen(), 35 | RecorderScreen.routeName: (context) => const RecorderScreen(), 36 | RecordingsScreen.routeName: (context) => const RecordingsScreen(), 37 | SoundChangerScreen.routeName: (context) => const SoundChangerScreen(), 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /lib/domain/common/service/permission_handler_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'package:injectable/injectable.dart'; 2 | import 'package:permission_handler/permission_handler.dart' as permission_handler; 3 | import 'package:voice_changer/domain/common/service/permission_handler_service.dart'; 4 | 5 | @Injectable(as: PermissionHandlerService) 6 | class PermissionHandlerServiceImpl implements PermissionHandlerService { 7 | static const String deniedMessage = 8 | 'microphone permission was denied, please grant' 9 | ' it to continue using the app'; 10 | static const String deniedPermanentlyMessage = 11 | 'microphone permission was denied permanently, please grant' 12 | ' it to continue using the app'; 13 | 14 | @override 15 | Future requestMicrophonePermission() async { 16 | permission_handler.PermissionStatus status = await permission_handler.Permission.microphone.request(); 17 | if (status.isGranted) { 18 | return const PermissionStatus.granted(); 19 | } 20 | if (status.isPermanentlyDenied) { 21 | return const PermissionStatus.deniedPermanently(); 22 | } 23 | return const PermissionStatus.denied(); 24 | } 25 | 26 | @override 27 | Future checkMicrophonePermission() async { 28 | bool isGranted = await permission_handler.Permission.microphone.isGranted; 29 | if (isGranted) { 30 | return const PermissionStatus.granted(); 31 | } 32 | return const PermissionStatus.denied(); 33 | } 34 | 35 | @override 36 | Future openSettingsApp() async { 37 | bool result = await permission_handler.openAppSettings(); 38 | return result; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | voice_changer 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/bloc/sound_changer_bloc_event.dart: -------------------------------------------------------------------------------- 1 | part of 'sound_changer_bloc.dart'; 2 | 3 | @freezed 4 | class SoundChangerBlocEvent with _$SoundChangerBlocEvent { 5 | const factory SoundChangerBlocEvent.init( 6 | {required RecordingDetails recording}) = _Init; 7 | 8 | //tempo 9 | const factory SoundChangerBlocEvent.shouldChangeTempoChanged(bool newValue) = 10 | _ShouldChangeTempoChanged; 11 | 12 | const factory SoundChangerBlocEvent.tempoChanged(double newValue) = 13 | _TempoChanged; 14 | 15 | //echo 16 | const factory SoundChangerBlocEvent.shouldAddEchoChanged(bool newValue) = 17 | _ShouldAddEcho; 18 | 19 | //trim 20 | const factory SoundChangerBlocEvent.shouldTrimChanged(bool newValue) = 21 | _ShouldTrimChanged; 22 | 23 | const factory SoundChangerBlocEvent.trimStartChanged(int newValue) = 24 | _TrimStartChanged; 25 | 26 | const factory SoundChangerBlocEvent.trimEndChanged(int newValue) = 27 | _TrimEndChanged; 28 | 29 | //sample rate 30 | const factory SoundChangerBlocEvent.shouldChangeSampleRateChanged( 31 | bool newValue) = _ShouldChangeSampleRateChanged; 32 | 33 | const factory SoundChangerBlocEvent.sampleRateChanged(int newValue) = 34 | _SampleRateChanged; 35 | 36 | //volume 37 | const factory SoundChangerBlocEvent.shouldChangeVolumeChanged(bool newValue) = 38 | _ShouldChangeVolumeChanged; 39 | 40 | const factory SoundChangerBlocEvent.volumeChanged(double newValue) = 41 | _VolumeChanged; 42 | 43 | //reverse 44 | const factory SoundChangerBlocEvent.shouldReverseChanged(bool newValue) = 45 | _ShouldReverseChanged; 46 | 47 | const factory SoundChangerBlocEvent.applyEffects(String outputFileName) = 48 | _ApplyEffects; 49 | } 50 | -------------------------------------------------------------------------------- /lib/domain/common/service/permission_handler_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'permission_handler_service.freezed.dart'; 4 | 5 | ///A service for managing permissions for the app. 6 | abstract class PermissionHandlerService { 7 | /// Requests microphone permission (**it does not open app settings**). 8 | ///

9 | ///

10 | /// Returns: 11 | /// * [PermissionStatus.granted] if granted 12 | /// * [PermissionStatus.denied] if denied 13 | /// * [PermissionStatus.deniedPermanently] if denied permanently 14 | Future requestMicrophonePermission(); 15 | 16 | ///Checks the status of the microphone permission. 17 | ///

18 | ///

19 | ///Returns: 20 | /// * [PermissionStatus.granted] if granted 21 | /// * [PermissionStatus.denied] if denied 22 | Future checkMicrophonePermission(); 23 | 24 | ///Opens the settings app. 25 | ///

26 | ///

27 | /// Returns: 28 | /// * true if it could be opened 29 | /// * false if it couldn't be opened. 30 | Future openSettingsApp(); 31 | } 32 | 33 | ///Represents the state of a permission, which may be: 34 | @freezed 35 | class PermissionStatus with _$PermissionStatus { 36 | const PermissionStatus._(); 37 | 38 | ///The permission is granted 39 | const factory PermissionStatus.granted() = _Granted; 40 | 41 | ///The permission is denied (it is not known if it is denied once or permanently) 42 | const factory PermissionStatus.denied() = _Denied; 43 | 44 | ///The permission is denied permanently 45 | const factory PermissionStatus.deniedPermanently() = 46 | _DeniedPermanently; 47 | 48 | bool get isGranted => this is _Granted; 49 | 50 | bool get isDenied => this is _Denied; 51 | 52 | bool get isDeniedPermanently => this is _DeniedPermanently; 53 | } 54 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 26 | 27 | android { 28 | compileSdkVersion 30 29 | 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | 35 | defaultConfig { 36 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 37 | applicationId "com.sillyapps.voice_changer" 38 | minSdkVersion 24 39 | targetSdkVersion 30 40 | versionCode flutterVersionCode.toInteger() 41 | versionName flutterVersionName 42 | } 43 | 44 | buildTypes { 45 | release { 46 | // TODO: Add your own signing config for the release build. 47 | // Signing with the debug keys for now, so `flutter run --release` works. 48 | signingConfig signingConfigs.debug 49 | } 50 | } 51 | } 52 | 53 | flutter { 54 | source '../..' 55 | } 56 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/widget/sound_changer_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide ErrorWidget; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:voice_changer/configuration/service_locator.dart'; 4 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 5 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 6 | import 'package:voice_changer/presentation/common/error_widget.dart'; 7 | import 'package:voice_changer/presentation/common/loading_widget.dart'; 8 | import 'package:voice_changer/presentation/recordings_screen/widget/recordings_screen.dart'; 9 | import 'package:voice_changer/presentation/sound_changer_screen/bloc/sound_changer_bloc.dart'; 10 | import 'package:voice_changer/presentation/styles/styles.dart'; 11 | 12 | part 'sound_changer_options.dart'; 13 | part 'sound_changer_screen_components.dart'; 14 | 15 | class SoundChangerScreen extends StatelessWidget { 16 | static const String routeName = '/sound-changer-screen'; 17 | 18 | const SoundChangerScreen({Key? key}) : super(key: key); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final recording = 23 | ModalRoute.of(context)!.settings.arguments as RecordingDetails; 24 | return BlocProvider( 25 | create: (context) => serviceLocator.get() 26 | ..add(SoundChangerBlocEvent.init(recording: recording)), 27 | child: Builder( 28 | builder: (context) => 29 | BlocBuilder( 30 | builder: (context, soundChangerBlocState) { 31 | if (soundChangerBlocState.isError) { 32 | return ErrorWidget(soundChangerBlocState.errorMessage!); 33 | } 34 | if (soundChangerBlocState.isInitialized) { 35 | return const _SoundChangerScreenComponents(); 36 | } 37 | return const LoadingWidget(); 38 | }, 39 | ), 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/domain/recording_details/recording_details_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:flutter_ffmpeg/flutter_ffmpeg.dart'; 5 | import 'package:injectable/injectable.dart'; 6 | import 'package:logger/logger.dart'; 7 | import 'package:voice_changer/configuration/service_locator.dart'; 8 | import 'package:voice_changer/domain/common/exception/failure.dart'; 9 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 10 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 11 | 12 | @Injectable(as: RecordingDetailsService) 13 | class RecordingDetailsServiceImpl implements RecordingDetailsService { 14 | final Logger _logger = serviceLocator.get(param1: Level.debug); 15 | 16 | @override 17 | Future> getRecordingDetails( 18 | File recordingFile) async { 19 | try { 20 | Duration? duration; 21 | final ffprobe = FlutterFFprobe(); 22 | final mediaInfo = await ffprobe.getMediaInformation(recordingFile.path); 23 | String? durationString = mediaInfo.getMediaProperties()?['duration']; 24 | if (durationString != null) { 25 | duration = _parseDuration(durationString); 26 | } 27 | return Right( 28 | RecordingDetails( 29 | path: recordingFile.path, 30 | name: FileExtension.getName(recordingFile.path), 31 | duration: duration, 32 | ), 33 | ); 34 | } catch (e) { 35 | _logger.e('error occurred in getRecordingDetails()', e); 36 | return Left( 37 | Failure( 38 | Failure.defaultErrorMessage, 39 | const ErrorCode.getRecordingDetailsError(), 40 | ), 41 | ); 42 | } 43 | } 44 | 45 | Duration _parseDuration(String durationString) { 46 | //example of the durationString: 1.536000 47 | int seconds = int.parse(durationString.split('.')[0]); 48 | int milliseconds = 49 | int.parse(durationString.split('.')[1].substring(0, 1)) * 100; 50 | return Duration( 51 | seconds: seconds, 52 | milliseconds: 53 | milliseconds); // returns Duration of 1 sec and 500 ms for the above example 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 14 | 18 | 21 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | 36 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/bloc/sound_changer_bloc_state.dart: -------------------------------------------------------------------------------- 1 | part of 'sound_changer_bloc.dart'; 2 | 3 | @freezed 4 | class SoundChangerBlocState with _$SoundChangerBlocState { 5 | const SoundChangerBlocState._(); 6 | 7 | const factory SoundChangerBlocState({ 8 | ///[recording] : the recording being edited 9 | RecordingDetails? recording, 10 | @Default(false) bool isProcessing, 11 | @Default(false) bool isInitialized, 12 | @Default(false) bool isError, 13 | String? errorMessage, 14 | //tempo 15 | @Default(false) bool shouldChangeTempo, 16 | @Default(SoundChangerService.defaultTempo) double tempo, 17 | //echo 18 | @Default(false) bool shouldAddEcho, 19 | @Default(SoundChangerService.defaultEchoInputGain) double echoInputGain, 20 | @Default(SoundChangerService.defaultEchoOutputGain) double echoOutputGain, 21 | @Default(SoundChangerService.defaultEchoDelay) double echoDelay, 22 | @Default(SoundChangerService.defaultEchoDecay) double echoDecay, 23 | //trim 24 | @Default(false) bool shouldTrim, 25 | @Default(0) int trimStart, 26 | @Default(0) int trimEnd, 27 | //sample rate 28 | @Default(false) bool shouldChangeSampleRate, 29 | @Default(SoundChangerService.defaultSampleRate) int sampleRate, 30 | //volume 31 | @Default(false) bool shouldChangeVolume, 32 | @Default(SoundChangerService.defaultVolume) double volume, 33 | //reverse 34 | @Default(false) bool shouldReverse, 35 | }) = _SoundChangerBlocState; 36 | 37 | @override 38 | String toString() => '\nSoundChangerBlocState{\n' 39 | 'recording: $recording\n' 40 | 'isProcessing: $isProcessing\n' 41 | 'isInitialized: $isInitialized\n' 42 | 'isError: $isError\n' 43 | 'errorMessage: $errorMessage\n' 44 | 'shouldChangeTempo: $shouldChangeTempo\n' 45 | 'tempo: $tempo\n' 46 | 'shouldAddEcho: $shouldAddEcho\n' 47 | 'echoInputGain: $echoInputGain\n' 48 | 'echoOutputGain: $echoOutputGain\n' 49 | 'echoDelay: $echoDelay\n' 50 | 'echoDecay: $echoDecay\n' 51 | 'shouldTrim: $shouldTrim\n' 52 | 'trimStart: $trimStart\n' 53 | 'trimEnd: $trimEnd\n' 54 | 'shouldChangeSampleRate: $shouldChangeSampleRate\n' 55 | 'sampleRate: $sampleRate\n' 56 | 'sampleRate: $shouldChangeVolume\n' 57 | 'volume: $volume\n' 58 | 'shouldReverse: $shouldReverse\n' 59 | '}\n'; 60 | } 61 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/recorder_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide ErrorWidget; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:logger/logger.dart'; 4 | import 'package:voice_changer/configuration/service_locator.dart'; 5 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 6 | import 'package:voice_changer/presentation/common/error_widget.dart'; 7 | import 'package:voice_changer/presentation/common/filled_circle.dart'; 8 | import 'package:voice_changer/presentation/common/filled_rectangle.dart'; 9 | import 'package:voice_changer/presentation/common/loading_widget.dart'; 10 | import 'package:voice_changer/presentation/recorder_screen/bloc/permission_bloc/permission_bloc.dart'; 11 | import 'package:voice_changer/presentation/recorder_screen/bloc/recorder_bloc/recorder_bloc.dart'; 12 | import 'package:voice_changer/presentation/recordings_screen/widget/recordings_screen.dart'; 13 | import 'package:voice_changer/presentation/styles/styles.dart'; 14 | 15 | part 'record_button.dart'; 16 | 17 | part 'recorder_icon_widget.dart'; 18 | 19 | part 'recorder_screen_components.dart'; 20 | 21 | part 'recordings_button.dart'; 22 | 23 | part 'stop_button.dart'; 24 | 25 | class RecorderScreen extends StatelessWidget { 26 | static const routeName = '/recorder-screen'; 27 | 28 | const RecorderScreen({Key? key}) : super(key: key); 29 | 30 | @override 31 | Widget build(BuildContext context) => MultiBlocProvider( 32 | providers: [ 33 | BlocProvider( 34 | create: (context) => serviceLocator.get() 35 | ..add(const RecorderBlocEvent.init()), 36 | ), 37 | BlocProvider( 38 | create: (context) => serviceLocator.get(), 39 | ) 40 | ], 41 | child: Builder( 42 | //The builder is to ensure that the blocs are available in the nested widget's context 43 | builder: (context) => BlocBuilder( 44 | builder: (context, recorderBlocState) { 45 | if (recorderBlocState.isError) { 46 | return ErrorWidget(recorderBlocState.errorMessage!); 47 | } 48 | if (recorderBlocState.recorderState.isInitialized) { 49 | return _RecorderScreenComponents(); 50 | } 51 | return const LoadingWidget(); 52 | }, 53 | ), 54 | ), 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/widget/recordings_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' hide ErrorWidget; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:logger/logger.dart'; 4 | import 'package:voice_changer/configuration/service_locator.dart'; 5 | import 'package:voice_changer/presentation/common/error_widget.dart'; 6 | import 'package:voice_changer/presentation/common/loading_widget.dart'; 7 | import 'package:voice_changer/presentation/recordings_screen/bloc/player_bloc/player_bloc.dart'; 8 | import 'package:voice_changer/presentation/recordings_screen/bloc/recordings_bloc/recordings_bloc.dart'; 9 | import 'package:voice_changer/presentation/sound_changer_screen/widget/sound_changer_screen.dart'; 10 | import 'package:voice_changer/presentation/styles/styles.dart'; 11 | 12 | part 'recording_tile_contents.dart'; 13 | part 'recordings_listview.dart'; 14 | part 'recordings_screen_components.dart'; 15 | 16 | class RecordingsScreen extends StatelessWidget { 17 | static const routeName = '/recordings-screen'; 18 | 19 | const RecordingsScreen({Key? key}) : super(key: key); 20 | 21 | @override 22 | Widget build(BuildContext context) => MultiBlocProvider( 23 | providers: [ 24 | BlocProvider( 25 | create: (_) => serviceLocator.get() 26 | ..add(const RecordingsBlocEvent.refresh()), 27 | ), 28 | BlocProvider( 29 | create: (_) => serviceLocator.get() 30 | ..add(const PlayerBlocEvent.init()), 31 | ), 32 | ], 33 | child: Builder( 34 | builder: (context) => 35 | BlocBuilder( 36 | builder: (context, recordingsBlocState) => 37 | BlocBuilder( 38 | builder: (context, playerBlocState) { 39 | if (playerBlocState.isError || recordingsBlocState.isError) { 40 | return ErrorWidget( 41 | 'playerBloc: ${playerBlocState.errorMessage ?? 'no error'}' 42 | ' and recordingsBloc: ${recordingsBlocState.errorMessage ?? 'no error'}'); 43 | } else { 44 | if (playerBlocState.playerState.isInitialized && 45 | recordingsBlocState.isInitialized) { 46 | return const _RecordingsScreenComponents(); 47 | } else { 48 | return const LoadingWidget(); 49 | } 50 | } 51 | }, 52 | ), 53 | ), 54 | ), 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/recorder_icon_widget.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_screen.dart'; 2 | 3 | class _RecorderIconWidget extends StatelessWidget { 4 | final double _containerSide; 5 | 6 | const _RecorderIconWidget(this._containerSide); 7 | 8 | @override 9 | Widget build(BuildContext context) => 10 | BlocBuilder( 11 | builder: (context, recorderBlocState) { 12 | double sizeFactor = (recorderBlocState.recorderState.isRecording 13 | ? (1 + recorderBlocState.volume / 100) 14 | : 1); 15 | sizeFactor %= 2; //to ensure r1<_containerSide 16 | 17 | final r1 = _containerSide / 2; 18 | final r2 = r1 / 1.08; 19 | final r3 = r2 / 1.08; 20 | final r4 = r3 / 3; 21 | 22 | final micIcon = Icon( 23 | Icons.mic_none_sharp, 24 | color: Colors.black38, 25 | size: r4, 26 | ); 27 | return SizedBox( 28 | width: _containerSide, 29 | height: _containerSide, 30 | child: Stack( 31 | alignment: Alignment.center, 32 | children: [ 33 | Opacity( 34 | opacity: 0.2, 35 | child: AnimatedScale( 36 | duration: const Duration(milliseconds: 200), 37 | scale: sizeFactor, 38 | child: FilledCircle( 39 | radius: r1, 40 | color: Theme.of(context).colorScheme.secondary, 41 | ), 42 | ), 43 | ), 44 | FilledCircle( 45 | color: Theme.of(context).colorScheme.primary, 46 | radius: r1, 47 | ), 48 | FilledCircle( 49 | color: Theme.of(context).colorScheme.secondary, 50 | radius: r2, 51 | ), 52 | FilledCircle( 53 | color: Colors.white, 54 | radius: r3, 55 | ), 56 | AnimatedSwitcher( 57 | duration: const Duration(milliseconds: 300), 58 | child: (!recorderBlocState.recorderState.isRecording) 59 | ? micIcon 60 | : Text( 61 | recorderBlocState.duration.toString(), 62 | ), 63 | transitionBuilder: (child, animation) => ScaleTransition( 64 | child: child, 65 | scale: animation, 66 | ), 67 | ), 68 | ], 69 | ), 70 | ); 71 | }, 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/recorder_screen_components.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_screen.dart'; 2 | 3 | ///The widget of the recorder screen to show if there is no error 4 | ///Above it in the tree there are bloc builders for recorder and permission blocs 5 | /// so these two blocs can be queried inside it and it will rebuild when they change. 6 | class _RecorderScreenComponents extends StatefulWidget { 7 | @override 8 | State<_RecorderScreenComponents> createState() => 9 | _RecorderScreenComponentsState(); 10 | } 11 | 12 | class _RecorderScreenComponentsState extends State<_RecorderScreenComponents> 13 | with WidgetsBindingObserver { 14 | final Logger _logger = serviceLocator.get(param1: Level.nothing); 15 | 16 | @override 17 | void initState() { 18 | super.initState(); 19 | WidgetsBinding.instance!.addObserver(this); 20 | } 21 | 22 | @override 23 | void dispose() { 24 | WidgetsBinding.instance!.removeObserver(this); 25 | super.dispose(); 26 | } 27 | 28 | @override 29 | void didChangeAppLifecycleState(AppLifecycleState state) { 30 | switch (state) { 31 | case AppLifecycleState.inactive: 32 | _logger.d('app inactive'); 33 | BlocProvider.of(context, listen: false) 34 | .add(const RecorderBlocEvent.appGoInactive()); 35 | break; 36 | case AppLifecycleState.resumed: 37 | _logger.d('app resumed'); 38 | BlocProvider.of(context, listen: false) 39 | .add(const PermissionBlocEvent.checkMicrophonePermission()); 40 | break; 41 | default: 42 | } 43 | } 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | final mq = MediaQuery.of(context); 48 | final width = mq.size.width; 49 | final height = mq.size.height; 50 | return Scaffold( 51 | resizeToAvoidBottomInset: false, 52 | appBar: AppBar( 53 | title: Text('Recorder', style: mediumText), 54 | centerTitle: true, 55 | ), 56 | body: Padding( 57 | padding: const EdgeInsets.all(20.0), 58 | child: Stack( 59 | children: [ 60 | Positioned( 61 | width: width - 40, 62 | height: width - 40, 63 | top: height / 17, 64 | left: 0, 65 | child: _RecorderIconWidget(width - 40), 66 | ), 67 | Positioned( 68 | bottom: 0, 69 | left: width / 10, 70 | child: _StopButton(), 71 | ), 72 | Positioned( 73 | bottom: 0, 74 | left: width / 4, 75 | right: width / 4, 76 | child: _RecordButton(), 77 | ), 78 | Positioned( 79 | bottom: 0, 80 | right: width / 10, 81 | child: _RecordingsButton(), 82 | ), 83 | ], 84 | ), 85 | ), 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/domain/common/exception/failure.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'failure.freezed.dart'; 4 | 5 | class Failure { 6 | ///The default error message that is returned to be displayed to 7 | ///the user in case of error 8 | static const String defaultErrorMessage = 9 | 'An error has occurred, please refresh or restart the app'; 10 | 11 | ///represents a message that can be displayed to the user 12 | final String message; 13 | 14 | ///can be used by the app to identify the source and nature of the error 15 | ///from a predefined list of errors present at [ErrorCode] class 16 | final ErrorCode errorCode; 17 | 18 | Failure(this.message, this.errorCode); 19 | 20 | @override 21 | String toString() { 22 | return 'Failure{message: $message, errorCode: $errorCode}'; 23 | } 24 | } 25 | 26 | @freezed 27 | class ErrorCode with _$ErrorCode { 28 | const factory ErrorCode.initRecorderError() = _InitRecorderError; 29 | 30 | const factory ErrorCode.disposeRecorderError() = _DisposeRecorderError; 31 | 32 | const factory ErrorCode.startRecorderError() = _StartRecorderError; 33 | 34 | const factory ErrorCode.pauseRecorderError() = _PauseRecorderError; 35 | 36 | const factory ErrorCode.resumeRecorderError() = _ResumeRecorderError; 37 | 38 | const factory ErrorCode.stopRecorderError() = _StopRecorderError; 39 | 40 | const factory ErrorCode.initPlayerError() = _InitPlayerError; 41 | 42 | const factory ErrorCode.disposePlayerError() = _DisposePlayerError; 43 | 44 | const factory ErrorCode.startPlayerError() = _StartPlayerError; 45 | 46 | const factory ErrorCode.pausePlayerError() = _PausePlayerError; 47 | 48 | const factory ErrorCode.resumePlayerError() = _ResumePlayerError; 49 | 50 | const factory ErrorCode.stopPlayerError() = _StopPlayerError; 51 | 52 | const factory ErrorCode.seekToPositionError() = _SeekToPositionError; 53 | 54 | const factory ErrorCode.addEchoError() = _AddEchoError; 55 | 56 | const factory ErrorCode.changeTempoError() = _ChangeTempoError; 57 | 58 | const factory ErrorCode.reverseAudioError() = _ReverseAudioError; 59 | 60 | const factory ErrorCode.setSampleRateError() = _SetSampleRateError; 61 | 62 | const factory ErrorCode.setVolumeError() = _SetVolumeError; 63 | 64 | const factory ErrorCode.trimSoundError() = _TrimSoundError; 65 | 66 | // const factory ErrorCode.getTrackDetailsError() = _GetTrackDetailsError; 67 | // const factory ErrorCode.changeDelayError() = _ChangeDelayError; 68 | // const factory ErrorCode.changeEchoError() = _ChangeEchoError; 69 | // const factory ErrorCode.applyEffectsError() = _ApplyEffectsError; 70 | // const factory ErrorCode.getDefaultStorageDirectoryError() = _GetDefaultStorageDirectoryError; 71 | 72 | const factory ErrorCode.createFileError() = _CreateFileError; 73 | 74 | const factory ErrorCode.deleteFileError() = _DeleteFileError; 75 | 76 | const factory ErrorCode.renameFileError() = _RenameFileError; 77 | 78 | const factory ErrorCode.getRecordingDetailsError() = 79 | _GetRecordingDetailsError; 80 | 81 | const factory ErrorCode.getDefaultStorageDirectoryError() = 82 | _GetDefaultStorageDirectoryError; 83 | } 84 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/widget/recordings_screen_components.dart: -------------------------------------------------------------------------------- 1 | part of 'recordings_screen.dart'; 2 | 3 | class _RecordingsScreenComponents extends StatefulWidget { 4 | const _RecordingsScreenComponents({Key? key}) : super(key: key); 5 | 6 | @override 7 | State<_RecordingsScreenComponents> createState() => 8 | _RecordingsScreenComponentsState(); 9 | } 10 | 11 | class _RecordingsScreenComponentsState 12 | extends State<_RecordingsScreenComponents> with WidgetsBindingObserver { 13 | final Logger _logger = serviceLocator.get(param1: Level.nothing); 14 | 15 | @override 16 | initState() { 17 | super.initState(); 18 | Future.delayed(Duration.zero, () => _showSnackBar()); 19 | WidgetsBinding.instance!.addObserver(this); 20 | } 21 | 22 | ScaffoldFeatureController _showSnackBar() { 23 | return ScaffoldMessenger.of(context).showSnackBar( 24 | SnackBar( 25 | content: Text( 26 | 'swipe to delete a recording', 27 | textAlign: TextAlign.center, 28 | style: mediumText, 29 | ), 30 | duration: Duration(seconds: 2), 31 | ), 32 | ); 33 | } 34 | 35 | @override 36 | dispose() { 37 | WidgetsBinding.instance!.removeObserver(this); 38 | super.dispose(); 39 | } 40 | 41 | @override 42 | void didChangeAppLifecycleState(AppLifecycleState state) { 43 | switch (state) { 44 | case AppLifecycleState.inactive: 45 | _logger.d('app inactive'); 46 | BlocProvider.of(context, listen: false) 47 | .add(const PlayerBlocEvent.appGoInactive()); 48 | break; 49 | default: 50 | } 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) => 55 | BlocBuilder( 56 | builder: (context, playerBlocState) { 57 | return BlocBuilder( 58 | builder: (context, recordingsBlocState) { 59 | final mq = MediaQuery.of(context); 60 | final width = mq.size.width; 61 | final height = mq.size.height; 62 | bool isProcessing = playerBlocState.isProcessing || 63 | recordingsBlocState.isProcessing; 64 | return Scaffold( 65 | appBar: AppBar( 66 | title: Text( 67 | 'Recordings', 68 | style: mediumText, 69 | ), 70 | centerTitle: true, 71 | leading: InkWell( 72 | child: const Icon(Icons.arrow_back), 73 | onTap: isProcessing 74 | ? null 75 | : () { 76 | BlocProvider.of(context) 77 | .add(const PlayerBlocEvent.stop()); 78 | Navigator.of(context).pop(); 79 | }, 80 | ), 81 | ), 82 | body: Stack( 83 | children: [ 84 | if (isProcessing) 85 | SizedBox( 86 | width: width, 87 | height: height, 88 | child: const IgnorePointer(), 89 | ), 90 | const _RecordingsListView(), 91 | ], 92 | ), 93 | ); 94 | }, 95 | ); 96 | }, 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /lib/configuration/service_locator.config.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | // ************************************************************************** 4 | // InjectableConfigGenerator 5 | // ************************************************************************** 6 | 7 | import 'package:get_it/get_it.dart' as _i1; 8 | import 'package:injectable/injectable.dart' as _i2; 9 | import 'package:logger/logger.dart' as _i4; 10 | 11 | import '../domain/common/service/filesystem_service.dart' as _i3; 12 | import '../domain/common/service/permission_handler_service.dart' as _i5; 13 | import '../domain/common/service/permission_handler_service_impl.dart' as _i6; 14 | import '../domain/player/player_service.dart' as _i7; 15 | import '../domain/player/player_service_impl.dart' as _i8; 16 | import '../domain/recorder/recorder_service.dart' as _i9; 17 | import '../domain/recorder/recorder_service_impl.dart' as _i10; 18 | import '../domain/recording_details/recording_details_service.dart' as _i11; 19 | import '../domain/recording_details/recording_details_service_impl.dart' 20 | as _i12; 21 | import '../domain/sound_changer/sound_changer_service.dart' as _i14; 22 | import '../domain/sound_changer/sound_changer_service_impl.dart' as _i15; 23 | import '../presentation/recorder_screen/bloc/permission_bloc/permission_bloc.dart' 24 | as _i16; 25 | import '../presentation/recorder_screen/bloc/recorder_bloc/recorder_bloc.dart' 26 | as _i18; 27 | import '../presentation/recordings_screen/bloc/player_bloc/player_bloc.dart' 28 | as _i17; 29 | import '../presentation/recordings_screen/bloc/recordings_bloc/recordings_bloc.dart' 30 | as _i13; 31 | import '../presentation/sound_changer_screen/bloc/sound_changer_bloc.dart' 32 | as _i19; 33 | import 'service_locator.dart' as _i20; // ignore_for_file: unnecessary_lambdas 34 | 35 | // ignore_for_file: lines_longer_than_80_chars 36 | /// initializes the registration of provided dependencies inside of [GetIt] 37 | _i1.GetIt $initGetIt(_i1.GetIt get, 38 | {String? environment, _i2.EnvironmentFilter? environmentFilter}) { 39 | final gh = _i2.GetItHelper(get, environment, environmentFilter); 40 | final registerModule = _$RegisterModule(); 41 | gh.factory<_i3.FileSystemService>(() => _i3.FileSystemService()); 42 | gh.factoryParam<_i4.Logger, _i4.Level?, _i4.LogPrinter?>( 43 | (logLevel, printer) => registerModule.logger(logLevel, printer)); 44 | gh.factory<_i5.PermissionHandlerService>( 45 | () => _i6.PermissionHandlerServiceImpl()); 46 | gh.factory<_i7.PlayerService>(() => _i8.PLayerServiceImpl()); 47 | gh.factory<_i9.RecorderService>(() => _i10.RecorderServiceImpl()); 48 | gh.factory<_i11.RecordingDetailsService>( 49 | () => _i12.RecordingDetailsServiceImpl()); 50 | gh.factory<_i13.RecordingsBloc>(() => _i13.RecordingsBloc( 51 | get<_i3.FileSystemService>(), get<_i11.RecordingDetailsService>())); 52 | gh.factory<_i14.SoundChangerService>(() => _i15.SoundChangerServiceImpl()); 53 | gh.factory<_i16.PermissionBloc>( 54 | () => _i16.PermissionBloc(get<_i5.PermissionHandlerService>())); 55 | gh.factory<_i17.PlayerBloc>(() => _i17.PlayerBloc(get<_i7.PlayerService>())); 56 | gh.factory<_i18.RecorderBloc>(() => _i18.RecorderBloc( 57 | get<_i9.RecorderService>(), get<_i3.FileSystemService>())); 58 | gh.factory<_i19.SoundChangerBloc>(() => _i19.SoundChangerBloc( 59 | get<_i14.SoundChangerService>(), get<_i3.FileSystemService>())); 60 | return get; 61 | } 62 | 63 | class _$RegisterModule extends _i20.RegisterModule {} 64 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /lib/domain/common/service/filesystem_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:injectable/injectable.dart'; 5 | import 'package:logger/logger.dart'; 6 | import 'package:path_provider/path_provider.dart' as path_provider; 7 | import 'package:voice_changer/configuration/service_locator.dart'; 8 | import 'package:voice_changer/domain/common/exception/failure.dart'; 9 | 10 | ///This class provides access to the file system. 11 | @Injectable() 12 | class FileSystemService { 13 | final Logger _logger = serviceLocator.get(); 14 | 15 | ///The default storage directory of the app 16 | Future> getDefaultStorageDirectory() async { 17 | try { 18 | Directory defaultStorageDirectory = 19 | (await path_provider.getApplicationDocumentsDirectory()); 20 | return Right(defaultStorageDirectory); 21 | } catch (e) { 22 | _logger.e('error occurred in _getDefaultStorageDirectory', e); 23 | return Left( 24 | Failure( 25 | Failure.defaultErrorMessage, 26 | const ErrorCode.getDefaultStorageDirectoryError(), 27 | ), 28 | ); 29 | } 30 | } 31 | 32 | ///creates a file object which will have a path as "[path]/[fileName].[extension]", and returns it. 33 | /// If file point to path of a file which already exists in the provided directory, it will be deleted first 34 | Future> createFile({ 35 | required String fileName, 36 | required String extension, 37 | required String path, 38 | }) async { 39 | try { 40 | File file = File('$path/$fileName.$extension'); 41 | bool exists = await file.exists(); 42 | if (exists) { 43 | await file.delete(); 44 | } 45 | return Right(file); 46 | } catch (e) { 47 | _logger.e('error occurred in createTempFile', e); 48 | return Left( 49 | Failure( 50 | Failure.defaultErrorMessage, 51 | const ErrorCode.createFileError(), 52 | ), 53 | ); 54 | } 55 | } 56 | 57 | Future> deleteFile(File file) async { 58 | try { 59 | await file.delete(); 60 | return const Right(null); 61 | } catch (e) { 62 | _logger.e('error occurred in deleteFile()', e); 63 | return Left( 64 | Failure( 65 | Failure.defaultErrorMessage, 66 | const ErrorCode.deleteFileError(), 67 | ), 68 | ); 69 | } 70 | } 71 | 72 | ///- [file] : The file to rename. The [fileName] must consist of only alphanumeric and underscore characters. 73 | ///- [extension] : The extension for the new file 74 | Future> renameFile({ 75 | required File file, 76 | required String newFileName, 77 | required String extension, 78 | }) async { 79 | try { 80 | final r = RegExp( 81 | r'^[a-zA-Z0-9_]*$'); //alphanumeric and underscores only for file name 82 | if (!r.hasMatch(newFileName) || newFileName.isEmpty) { 83 | throw Exception( 84 | '$newFileName is not a valid file name, it must only contain alphanumeric/underscore characters'); 85 | } 86 | final path = file.path; 87 | final lastSeparator = path.lastIndexOf(Platform.pathSeparator); 88 | final newPath = 89 | path.substring(0, lastSeparator + 1) + newFileName + '.$extension'; 90 | final renamedFile = await file.rename(newPath); 91 | return Right(renamedFile); 92 | } catch (e) { 93 | _logger.e('error occurred in renameFile()', e); 94 | return Left( 95 | Failure( 96 | Failure.defaultErrorMessage, 97 | const ErrorCode.renameFileError(), 98 | ), 99 | ); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/widget/recordings_listview.dart: -------------------------------------------------------------------------------- 1 | part of 'recordings_screen.dart'; 2 | 3 | class _RecordingsListView extends StatelessWidget { 4 | const _RecordingsListView({Key? key}) : super(key: key); 5 | 6 | @override 7 | Widget build(BuildContext context) => 8 | BlocBuilder( 9 | builder: (context, recordingsBlocState) => 10 | BlocBuilder( 11 | builder: (context, playerBlocState) { 12 | bool isPlaying = playerBlocState.playerState.isPlaying; 13 | bool isPaused = playerBlocState.playerState.isPaused; 14 | 15 | final playerBloc = BlocProvider.of(context); 16 | final recordingsBloc = BlocProvider.of(context); 17 | 18 | return ListView.builder( 19 | itemCount: recordingsBlocState.recordings.length, 20 | itemBuilder: (context, index) => Dismissible( 21 | // key: UniqueKey(), 22 | key: Key(recordingsBloc.state.recordings[index].path), 23 | direction: DismissDirection.startToEnd, 24 | background: _dismissibleBackground(context), 25 | confirmDismiss: (direction) async => 26 | await _confirmDismiss(context), 27 | onDismissed: (_) async { 28 | bool isPlayingTile = (isPlaying || isPaused) && 29 | recordingsBlocState.recordings[index] == 30 | playerBlocState.recording; 31 | if (isPlayingTile) { 32 | playerBloc.add(const PlayerBlocEvent.stop()); 33 | } 34 | recordingsBloc.add( 35 | RecordingsBlocEvent.deleteRecording( 36 | recordingsBlocState.recordings[index].path, 37 | ), 38 | ); 39 | }, 40 | child: _RecordingTileContents(index), 41 | ), 42 | ); 43 | }, 44 | ), 45 | ); 46 | 47 | Future _confirmDismiss(BuildContext context) async => await showDialog( 48 | context: context, 49 | builder: (context) => WillPopScope( 50 | onWillPop: () async => false, 51 | child: AlertDialog( 52 | title: Text( 53 | 'Delete recording?', 54 | style: mediumText, 55 | textAlign: TextAlign.center, 56 | ), 57 | actions: [ 58 | SimpleDialogOption( 59 | child: 60 | Text('No', style: mediumText.copyWith(color: Colors.blue)), 61 | onPressed: () => Navigator.of(context).pop(false), 62 | ), 63 | SimpleDialogOption( 64 | child: 65 | Text('Yes', style: mediumText.copyWith(color: Colors.red)), 66 | onPressed: () => Navigator.of(context).pop(true), 67 | ), 68 | ], 69 | contentPadding: const EdgeInsets.all(20), 70 | actionsAlignment: MainAxisAlignment.center, 71 | shape: const RoundedRectangleBorder( 72 | borderRadius: BorderRadius.all(Radius.circular(20))), 73 | ), 74 | ), 75 | ); 76 | 77 | Stack _dismissibleBackground(BuildContext context) => Stack( 78 | children: [ 79 | Container(color: Theme.of(context).errorColor), 80 | Align( 81 | alignment: Alignment.centerLeft, 82 | child: Padding( 83 | padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 30), 84 | child: Text('Release to delete', 85 | style: mediumText.copyWith(color: Colors.white)), 86 | ), 87 | ), 88 | ], 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/permission_bloc/permission_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:bloc/bloc.dart'; 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | import 'package:injectable/injectable.dart'; 6 | import 'package:logger/logger.dart'; 7 | import 'package:voice_changer/configuration/service_locator.dart'; 8 | import 'package:voice_changer/domain/common/exception/exception_utils.dart'; 9 | import 'package:voice_changer/domain/common/service/permission_handler_service.dart'; 10 | 11 | part 'permission_bloc.freezed.dart'; 12 | part 'permission_bloc_event.dart'; 13 | part 'permission_bloc_state.dart'; 14 | 15 | @Injectable() 16 | class PermissionBloc extends Bloc { 17 | final PermissionHandlerService _permissionHandlerService; 18 | final Logger _logger = serviceLocator.get(param1: Level.debug); 19 | 20 | PermissionBloc(this._permissionHandlerService) 21 | : super(const PermissionBlocState()) { 22 | on((event, emit) async { 23 | emit(await event.map( 24 | checkMicrophonePermission: _handleCheckMicrophonePermission, 25 | requestMicrophonePermission: _handleRequestMicrophonePermission, 26 | openSettingsApp: _handleOpenSettingsApp, 27 | )); 28 | }); 29 | } 30 | 31 | FutureOr _handleCheckMicrophonePermission( 32 | _CheckMicrophonePermissionEvent event) async => 33 | (await _permissionHandlerService.checkMicrophonePermission()).maybeWhen( 34 | granted: () async { 35 | if (event.onGranted != null) { 36 | await Future.value(event.onGranted!()); 37 | } 38 | return state.copyWith(isMicrophonePermissionGranted: true); 39 | }, 40 | denied: () async { 41 | if (event.onDenied != null) { 42 | await Future.value(event.onDenied!()); 43 | } 44 | return state.copyWith(isMicrophonePermissionGranted: false); 45 | }, 46 | orElse: () => crashWithMessage( 47 | 'an error has occurred: checkMicrophonePermission returned a wrong value'), 48 | ); 49 | 50 | FutureOr _handleRequestMicrophonePermission( 51 | _RequestMicrophonePermissionEvent event) async => 52 | (await _permissionHandlerService.requestMicrophonePermission()).when( 53 | granted: () async { 54 | if (event.onGranted != null) { 55 | await Future.value(event.onGranted!()); 56 | } 57 | return state.copyWith(isMicrophonePermissionGranted: true); 58 | }, 59 | denied: () async { 60 | if (event.onDenied != null) { 61 | await Future.value(event.onDenied!()); 62 | } 63 | return state.copyWith(isMicrophonePermissionGranted: false); 64 | }, 65 | deniedPermanently: () async { 66 | if (event.onPermanentlyDenied != null) { 67 | await Future.value(event.onPermanentlyDenied!()); 68 | } 69 | return state.copyWith(isMicrophonePermissionGranted: false); 70 | }, 71 | ); 72 | 73 | FutureOr _handleOpenSettingsApp( 74 | _OpenSettingsAppEvent event) async { 75 | await _permissionHandlerService.openSettingsApp(); 76 | return state; 77 | } 78 | 79 | @override 80 | void onEvent(event) { 81 | super.onEvent(event); 82 | _logger.d( 83 | '[PermissionBloc] event has arrived: \n$event\nwhile the state was \n$state\n'); 84 | } 85 | 86 | @override 87 | void onTransition(transition) { 88 | super.onTransition(transition); 89 | _logger.i( 90 | '[PermissionBloc] emitting a new state: \n${transition.nextState}\nin response to event \n${transition.event}\n'); 91 | } 92 | 93 | @override 94 | Future close() async { 95 | _logger.i('disposed permission bloc'); 96 | super.close(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/domain/sound_changer/sound_changer_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:voice_changer/domain/common/exception/failure.dart'; 5 | 6 | ///The contract of sound changer. 7 | abstract class SoundChangerService { 8 | static const double defaultTempo = 0.5; 9 | 10 | static const double defaultEchoInputGain = 1; 11 | static const double defaultEchoOutputGain = 1; 12 | static const double defaultEchoDelay = 250; 13 | static const double defaultEchoDecay = 1; 14 | 15 | static const int defaultSampleRate = 44100; 16 | 17 | static const double defaultVolume = 50; 18 | 19 | ///Changes the tempo. 20 | ///* [tempo] must be between 0.5 and 100. 21 | ///

22 | ///

23 | ///If [outputFile] exists, then it will be overwritten. 24 | Future> changeTempo({ 25 | required File inputFile, 26 | required File outputFile, 27 | required double tempo, 28 | }); //todo remove this ffmpeg -y -i ip_file -af atempo=tempo_value op_file 29 | 30 | ///Adds echo. 31 | ///- [inputGain] : input gain of reflected signal. Must be between 0 and 1 32 | ///- [outputGain] : output gain of reflected signal. Must be between 0 and 1 33 | ///- [delays] : list of time intervals in milliseconds between original signal and reflections. Allowed range for each delay is (0 - 90000.0]. 34 | ///- [decays] : list of loudness of reflected signals. Allowed range for each decay is (0 - 1.0] with 1.0 being the loudest. 35 | ///

36 | ///

37 | /// The length of the [delays] and [decays] lists must be the same (each delay corresponds to the decay at the same index) 38 | ///

39 | ///

40 | ///If [outputFile] exists, then it will be overwritten. 41 | Future> addEcho({ 42 | required File inputFile, 43 | required File outputFile, 44 | required double inputGain, 45 | required double outputGain, 46 | required List delays, 47 | required List decays, 48 | }); //todo remove this ffmpeg -y -i ip_file -af aecho=in_gain:out_gain:delay1|delay2|delay3:decay1|decay2|decay3 op_file 49 | 50 | ///Trims the audio at the supplied location and only keeps the part between [start] and [end]. [start] and [end] are in seconds 51 | ///- Constraints: [start] must be less than [end], and both [start] and [end] must be positive and less than audio duration 52 | ///

53 | ///

54 | ///If [outputFile] exists, then it will be overwritten. 55 | Future> trimSound({ 56 | required File inputFile, 57 | required File outputFile, 58 | required int start, 59 | required int end, 60 | }); // todo remove this ffmpeg -y -i ip -af 'atrim=start_time_in_seconds:end_time_in_seconds' op 61 | 62 | ///Set the sample rate without altering the PCM data. This will result in a change of speed and pitch. 63 | ///- [sampleRate] : must be between 8000 and 120000 64 | ///

65 | ///

66 | ///If [outputFile] exists, then it will be overwritten. 67 | Future> setSampleRate({ 68 | required File inputFile, 69 | required File outputFile, 70 | required int sampleRate, 71 | }); // todo remove this ffmpeg -y -i ip -af 'asetrate=sampling_rate_value' op 72 | 73 | ///Adjust the input audio volume. 74 | ///Must be between 0 (no sound) and 100 (highest value, but output volume will be clipped to the maximum value) 75 | ///

76 | ///If [outputFile] exists, then it will be overwritten. 77 | Future> setVolume({ 78 | required File inputFile, 79 | required File outputFile, 80 | required double volume, 81 | }); //todo remove this ffmpeg -y -i ip -af 'volume=volume_value' op 82 | 83 | ///Reverses the audio. 84 | ///

85 | ///If [outputFile] exists, then it will be overwritten. 86 | Future> reverseAudio({ 87 | required File inputFile, 88 | required File outputFile, 89 | }); //todo remove this ffmpeg -y -i ip -af 'areverse' op 90 | } 91 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/recordings_bloc/recordings_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:bloc/bloc.dart'; 5 | import 'package:freezed_annotation/freezed_annotation.dart'; 6 | import 'package:injectable/injectable.dart'; 7 | import 'package:logger/logger.dart'; 8 | import 'package:voice_changer/configuration/service_locator.dart'; 9 | import 'package:voice_changer/domain/common/exception/failure.dart'; 10 | import 'package:voice_changer/domain/common/extensions/directory_extensions.dart'; 11 | import 'package:voice_changer/domain/common/service/filesystem_service.dart'; 12 | import 'package:voice_changer/domain/recorder/recorder_service.dart'; 13 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 14 | 15 | part 'recordings_bloc.freezed.dart'; 16 | part 'recordings_bloc_event.dart'; 17 | part 'recordings_bloc_state.dart'; 18 | 19 | @Injectable() 20 | class RecordingsBloc extends Bloc { 21 | final Logger _logger = serviceLocator.get(param1: Level.debug); 22 | final FileSystemService _fileSystemService; 23 | final RecordingDetailsService _recordingDetailsService; 24 | 25 | RecordingsBloc(this._fileSystemService, this._recordingDetailsService) 26 | : super(const RecordingsBlocState()) { 27 | on( 28 | (event, emit) async { 29 | await event.map( 30 | refresh: (event) async=> await _handleRefreshEvent(event,emit), 31 | deleteRecording: (event) async => 32 | await _handleDeleteRecordingEvent(event, emit), 33 | ); 34 | }, 35 | ); 36 | } 37 | 38 | Future _handleRefreshEvent( 39 | _Refresh value, Emitter emit) async { 40 | emit(state.copyWith(isProcessing: true)); 41 | return (await _fileSystemService.getDefaultStorageDirectory()).fold( 42 | (f) async => _emitErrorState(emit, f), 43 | (defaultStorageDirectory) async { 44 | Failure? failure; 45 | List recordings = []; 46 | for (final file in defaultStorageDirectory.getFiles( 47 | extension: RecorderService.defaultCodec, 48 | )) { 49 | (await _recordingDetailsService.getRecordingDetails(file)).fold( 50 | (f) => failure = f, 51 | (recording) async { 52 | if (recording.duration == null) { 53 | (await _fileSystemService.deleteFile(file)) 54 | .fold((f) => failure = f, (_) {}); 55 | return; 56 | } 57 | recordings.add(recording); 58 | }, 59 | ); 60 | if (failure != null) { 61 | break; 62 | } 63 | } 64 | if (failure != null) { 65 | _emitErrorState(emit, failure!); 66 | } else { 67 | emit( 68 | state.copyWith( 69 | isInitialized: true, 70 | isProcessing: false, 71 | recordings: List.unmodifiable(recordings), 72 | // recordings: recordings, 73 | ), 74 | ); 75 | } 76 | }, 77 | ); 78 | } 79 | 80 | 81 | Future _handleDeleteRecordingEvent( 82 | _DeleteRecordingEvent event, Emitter emit) async { 83 | final tempRecordings = 84 | state.recordings.whereNot((rec) => rec.path == event.path).toList(); 85 | //emit a state with the selected recording removed,so that UI doesn't have to wait to update 86 | //in case of error no need to restore the previous recordings list 87 | emit(state.copyWith(isProcessing: true, recordings: tempRecordings)); 88 | return (await _fileSystemService.deleteFile(File(event.path))).fold( 89 | (f) async => _emitErrorState(emit, f), 90 | (_) async { 91 | emit( 92 | state.copyWith( 93 | isProcessing: false, 94 | recordings: List.unmodifiable(tempRecordings), 95 | // recordings: newRecordingsList, 96 | ), 97 | ); 98 | }, 99 | ); 100 | } 101 | 102 | void _emitErrorState(Emitter emit, Failure f) => 103 | emit(state.copyWith(isError: true, errorMessage: f.message)); 104 | 105 | @override 106 | void onEvent(event) { 107 | super.onEvent(event); 108 | _logger.d( 109 | '[RecordingsBloc] event has arrived: \n$event\nwhile the state was \n$state\n'); 110 | } 111 | 112 | @override 113 | void onTransition(transition) { 114 | super.onTransition(transition); 115 | _logger.i( 116 | '[RecordingsBloc] emitting a new state: \n${transition.nextState}\nin response to event \n${transition.event}\n'); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/record_button.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_screen.dart'; 2 | 3 | class _RecordButton extends StatefulWidget { 4 | @override 5 | State<_RecordButton> createState() => _RecordButtonState(); 6 | } 7 | 8 | class _RecordButtonState extends State<_RecordButton> { 9 | double _opacity = 0.0; //used to show animation when user taps the button 10 | 11 | int count = 0; 12 | 13 | @override 14 | Widget build(BuildContext context) => 15 | BlocBuilder( 16 | builder: (context, recorderBlocState) { 17 | final mq = MediaQuery.of(context); 18 | final width = mq.size.width; 19 | final r1 = width / 4; 20 | final r2 = r1 / 1.08; 21 | final r3 = r2 / 6; 22 | 23 | final recorderBloc = BlocProvider.of(context); 24 | 25 | final permissionBloc = BlocProvider.of(context); 26 | final isMicrophonePermissionGranted = 27 | permissionBloc.state.isMicrophonePermissionGranted; 28 | return SizedBox( 29 | width: r1, 30 | height: r1, 31 | child: GestureDetector( 32 | onPanDown: (_) => setState(() { 33 | _opacity = 1.0; 34 | }), 35 | onPanEnd: (_) => setState( 36 | () { 37 | _opacity = 0.0; 38 | }, 39 | ), 40 | onTap: () { 41 | setState(() { 42 | if (!recorderBlocState.recorderState.isRecording) { 43 | if (isMicrophonePermissionGranted) { 44 | recorderBloc 45 | .add(const RecorderBlocEvent.startRecording()); 46 | } else { 47 | permissionBloc.add( 48 | PermissionBlocEvent.requestMicrophonePermission( 49 | onPermanentlyDenied: () async { 50 | await _showPermissionDialog(context); 51 | }, 52 | onGranted: () => recorderBloc 53 | .add(const RecorderBlocEvent.startRecording()), 54 | ), 55 | ); 56 | } 57 | } 58 | _opacity = 0.0; 59 | }); 60 | }, 61 | child: Stack( 62 | alignment: Alignment.center, 63 | children: [ 64 | FilledCircle( 65 | color: recorderBlocState.recorderState.isRecording 66 | ? Theme.of(context).colorScheme.secondary 67 | : Theme.of(context).disabledColor, 68 | radius: r1, 69 | ), 70 | FilledCircle( 71 | color: Colors.white, 72 | radius: r2, 73 | ), 74 | FilledCircle( 75 | color: Colors.red, 76 | radius: r3, 77 | ), 78 | AnimatedOpacity( 79 | duration: const Duration(milliseconds: 150), 80 | opacity: _opacity, 81 | child: FilledCircle( 82 | color: Colors.black12, 83 | radius: r1, 84 | ), 85 | ), 86 | ], 87 | ), 88 | ), 89 | ); 90 | }, 91 | ); 92 | 93 | _showPermissionDialog(BuildContext context) async { 94 | final permissionBloc = BlocProvider.of(context); 95 | await showDialog( 96 | context: context, 97 | builder: (context) => AlertDialog( 98 | title: Column( 99 | children: [ 100 | Text( 101 | 'Permission Required', 102 | style: mediumText, 103 | textAlign: TextAlign.center, 104 | ), 105 | const Divider(thickness: 3), 106 | ], 107 | ), 108 | actionsAlignment: MainAxisAlignment.center, 109 | content: Text( 110 | 'Please grant the microphone permission to use the recorder.', 111 | style: smallText, 112 | textAlign: TextAlign.center, 113 | ), 114 | actions: [ 115 | TextButton( 116 | child: Text( 117 | 'Grant Permission', 118 | style: mediumText, 119 | ), 120 | onPressed: () { 121 | permissionBloc.add(const PermissionBlocEvent.openSettingsApp()); 122 | Navigator.of(context).pop(); 123 | }, 124 | ), 125 | ], 126 | shape: const RoundedRectangleBorder( 127 | borderRadius: BorderRadius.all(Radius.circular(18)), 128 | ), 129 | ), 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: voice_changer 2 | description: Voice Changer Application 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.12.0 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | 34 | # The following adds the Cupertino Icons font to your application. 35 | # Use with the CupertinoIcons class for iOS style icons. 36 | cupertino_icons: ^1.0.2 37 | 38 | #this dependency is for using sound recorder 39 | #flutter_sound: ^8.3.9 40 | flutter_sound_lite: ^8.3.12 41 | #this dependency is for using sound player 42 | just_audio: ^0.9.12 43 | #this dependency is for getting paths in the filesystem 44 | path_provider: ^2.0.5 45 | #this dependency is for handling permissions 46 | permission_handler: ^8.2.2 47 | #this dependency is for handling states inside widgets using BLoC pattern 48 | flutter_bloc: ^7.3.0 49 | #this dependency is for generating code and creating useful immutable objects 50 | freezed_annotation: ^0.14.3 51 | #these two dependencies provide usage for service locator and dependency injection 52 | get_it: ^7.2.0 53 | injectable: ^1.5.0 54 | #this dependency is for logging 55 | logger: ^1.1.0 56 | #this dependency is for using functional programming patterns and objects 57 | dartz: ^0.10.0 58 | #this dependency is for manipulating the audio files 59 | flutter_ffmpeg: ^0.4.2 60 | #this dependency is for using [BehaviourSubject]s 61 | rxdart: ^0.27.2 62 | 63 | dev_dependencies: 64 | flutter_test: 65 | sdk: flutter 66 | 67 | #this dependency is for running the code generator 68 | build_runner: 69 | freezed: 70 | injectable_generator: 71 | 72 | # The "flutter_lints" package below contains a set of recommended lints to 73 | # encourage good coding practices. The lint set provided by the package is 74 | # activated in the `analysis_options.yaml` file located at the root of your 75 | # package. See that file for information about deactivating specific lint 76 | # rules and activating additional ones. 77 | flutter_lints: ^1.0.4 78 | 79 | # For information on the generic Dart part of this file, see the 80 | # following page: https://dart.dev/tools/pub/pubspec 81 | 82 | # The following section is specific to Flutter. 83 | flutter: 84 | 85 | # The following line ensures that the Material Icons font is 86 | # included with your application, so that you can use the icons in 87 | # the material Icons class. 88 | uses-material-design: true 89 | 90 | # To add assets to your application, add an assets section, like this: 91 | # assets: 92 | # - images/a_dot_burr.jpeg 93 | # - images/a_dot_ham.jpeg 94 | 95 | # An image asset can refer to one or more resolution-specific "variants", see 96 | # https://flutter.dev/assets-and-images/#resolution-aware. 97 | 98 | # For details regarding adding assets from package dependencies, see 99 | # https://flutter.dev/assets-and-images/#from-packages 100 | 101 | # To add custom fonts to your application, add a fonts section here, 102 | # in this "flutter" section. Each entry in this list should have a 103 | # "family" key with the font family name, and a "fonts" key with a 104 | # list giving the asset and other descriptors for the font. For 105 | # example: 106 | # fonts: 107 | # - family: Schyler 108 | # fonts: 109 | # - asset: fonts/Schyler-Regular.ttf 110 | # - asset: fonts/Schyler-Italic.ttf 111 | # style: italic 112 | # - family: Trajan Pro 113 | # fonts: 114 | # - asset: fonts/TrajanPro.ttf 115 | # - asset: fonts/TrajanPro_Bold.ttf 116 | # weight: 700 117 | # 118 | # For details regarding fonts from package dependencies, 119 | # see https://flutter.dev/custom-fonts/#from-packages 120 | -------------------------------------------------------------------------------- /lib/domain/player/player_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:freezed_annotation/freezed_annotation.dart'; 6 | import 'package:voice_changer/domain/common/exception/failure.dart'; 7 | 8 | part 'player_service.freezed.dart'; 9 | 10 | ///The contract for sound player. 11 | ///It must be initialized before use, and must not be used after dispose 12 | abstract class PlayerService { 13 | ///Stream of the state of the player 14 | Stream get playerStateStream; 15 | 16 | ///Stream of the position in time of the current playback 17 | Stream get positionStream; 18 | 19 | ///The latest player state emitted 20 | PlayerState get playerState; 21 | 22 | /// Initializes the player. 23 | /// It is allowed to call this method if initialization has already happened,in which case the same current state is maintained. 24 | /// Usually this method must not result in any failures. 25 | /// The current state will not be changed if a failure happens. 26 | ///

27 | ///

28 | /// Returns an [Either] wrapping: 29 | ///* [InitPlayerResult], in case of success 30 | ///* [Failure], in case of failure (no change of the state happens in this case) 31 | Future> initPlayer(); 32 | 33 | /// Disposes the player, and stops it if it is recording. 34 | /// It is allowed to call this method if dispose has already happened. 35 | /// Usually this method must not result in any failures. 36 | /// The current state will not be changed if a failure happens. 37 | ///

38 | ///

39 | /// Returns an [Either] wrapping: 40 | ///* nothing, in case of success 41 | ///* [Failure], in case of failure (no change of the state happens in this case) 42 | Future> disposePlayer(); 43 | 44 | /// Starts the player. If it was already playing or paused then nothing happens. 45 | /// The current state will not be changed if a failure happens. 46 | ///

47 | ///

48 | ///A failure will happen if: 49 | ///* the player was uninitialized 50 | ///

51 | ///

52 | /// - [file] : the file to play 53 | /// - [onDone] : a callback to execute when the playback reaches the end or is stopped 54 | ///

55 | ///

56 | /// Returns an [Either] wrapping: 57 | ///* nothing, in case of success 58 | ///* [Failure], in case of failure (no change of the state happens in this case) 59 | Future> startPlayer({ 60 | required File file, 61 | Function? onDone, 62 | }); 63 | 64 | /// Pauses the player. 65 | /// If the player was not playing then nothing happens. 66 | /// The current state will not be changed if a failure happens. 67 | ///

68 | ///

69 | ///A failure will happen if: 70 | ///* the player was uninitialized 71 | ///

72 | ///

73 | /// Returns an [Either] wrapping: 74 | ///* nothing, in case of success 75 | ///* [Failure], in case of failure (no change of the state happens in this case) 76 | Future> pausePlayer(); 77 | 78 | /// Resumes the player. 79 | /// If the player was not paused then nothing happens. 80 | /// The current state will not be changed if a failure happens. 81 | ///

82 | ///

83 | ///A failure will happen if: 84 | ///* the player was uninitialized 85 | ///

86 | ///

87 | /// Returns an [Either] wrapping: 88 | ///* nothing, in case of success 89 | ///* [Failure], in case of failure (no change of the state happens in this case) 90 | Future> resumePlayer(); 91 | 92 | /// Stops the player. If it was not playing or paused, then nothing happens. 93 | /// The current state will not be changed if a failure happens. 94 | ///

95 | ///

96 | ///A failure will happen if: 97 | ///* the player was uninitialized 98 | ///

99 | ///

100 | /// Returns an [Either] wrapping: 101 | ///* nothing, in case of success 102 | ///* [Failure], in case of failure (no change of the state happens in this case) 103 | Future> stopPlayer(); 104 | 105 | ///Seeks the player to the desired position. If the player was not playing or paused, nothing happens. 106 | ///A failure will happen if player was uninitialized. 107 | Future> seekToPosition(Duration position); 108 | } 109 | 110 | class InitPlayerResult { 111 | final Stream playerStateStream; 112 | final Stream positionStream; 113 | 114 | InitPlayerResult({ 115 | required this.playerStateStream, 116 | required this.positionStream, 117 | }); 118 | } 119 | 120 | @freezed 121 | class PlayerState with _$PlayerState { 122 | const PlayerState._(); 123 | 124 | const factory PlayerState.uninitialized() = _UninitializedPlayerState; 125 | 126 | const factory PlayerState.playing() = _PlayingPlayerState; 127 | 128 | const factory PlayerState.paused() = _PausedPlayerState; 129 | 130 | const factory PlayerState.stopped() = _StoppedPlayerState; 131 | 132 | bool get isInitialized => this is! _UninitializedPlayerState; 133 | 134 | bool get isPlaying => this is _PlayingPlayerState; 135 | 136 | bool get isPaused => this is _PausedPlayerState; 137 | 138 | bool get isStopped => this is _StoppedPlayerState; 139 | } 140 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/widget/recording_tile_contents.dart: -------------------------------------------------------------------------------- 1 | part of 'recordings_screen.dart'; 2 | 3 | class _RecordingTileContents extends StatelessWidget { 4 | final double _iconSize = 40; 5 | 6 | final int _index; 7 | 8 | const _RecordingTileContents(this._index, {Key? key}) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) => 12 | BlocBuilder( 13 | builder: (context, recordingsBlocState) => 14 | BlocBuilder( 15 | builder: (context, playerBlocState) { 16 | bool isPlaying = playerBlocState.playerState.isPlaying; 17 | bool isPaused = playerBlocState.playerState.isPaused; 18 | bool isPlayingTile = (isPlaying || isPaused) && 19 | recordingsBlocState.recordings[_index] == 20 | playerBlocState.recording; 21 | return IgnorePointer( 22 | //only the playing tile can be expanded while playing, all other tiles are not tappable 23 | ignoring: ((isPlaying || isPaused) && !isPlayingTile), 24 | child: ExpansionTile( 25 | key: isPlayingTile 26 | ? Key(recordingsBlocState.recordings[_index].path) 27 | : UniqueKey(), 28 | initiallyExpanded: isPlayingTile, 29 | leading: const Icon(Icons.mic), 30 | title: _title(context), 31 | subtitle: _subTitle(context), 32 | children: [ 33 | Row( 34 | mainAxisAlignment: MainAxisAlignment.center, 35 | children: [ 36 | _playButton(context), 37 | _pauseButton(context), 38 | _stopButton(context), 39 | _editButton(context), 40 | if (isPlayingTile) _slider(context), 41 | ], 42 | ), 43 | ], 44 | ), 45 | ); 46 | }, 47 | ), 48 | ); 49 | 50 | _title(BuildContext context) { 51 | final recording = 52 | BlocProvider.of(context).state.recordings[_index]; 53 | return Text(recording.name, style: mediumText); 54 | } 55 | 56 | _subTitle(BuildContext context) { 57 | final recording = 58 | BlocProvider.of(context).state.recordings[_index]; 59 | return Text(recording.duration.toString(), style: mediumText); 60 | } 61 | 62 | _playButton(BuildContext context) { 63 | return InkWell( 64 | child: Icon( 65 | Icons.play_circle_outline, 66 | size: _iconSize, 67 | ), 68 | onTap: () { 69 | final playerBloc = BlocProvider.of(context); 70 | final recordingsBloc = BlocProvider.of(context); 71 | bool isPaused = playerBloc.state.playerState.isPaused; 72 | bool isStopped = playerBloc.state.playerState.isStopped; 73 | if (isPaused) { 74 | playerBloc.add(const PlayerBlocEvent.resume()); 75 | } 76 | if (isStopped) { 77 | playerBloc.add(PlayerBlocEvent.start( 78 | recording: recordingsBloc.state.recordings[_index])); 79 | } 80 | }, 81 | ); 82 | } 83 | 84 | _pauseButton(BuildContext context) { 85 | return InkWell( 86 | child: Icon( 87 | Icons.pause_circle_outline, 88 | size: _iconSize, 89 | ), 90 | onTap: () { 91 | final playerBloc = BlocProvider.of(context); 92 | bool isPlaying = playerBloc.state.playerState.isPlaying; 93 | if (isPlaying) { 94 | playerBloc.add(const PlayerBlocEvent.pause()); 95 | } 96 | }, 97 | ); 98 | } 99 | 100 | _stopButton(BuildContext context) { 101 | return InkWell( 102 | child: Icon( 103 | Icons.stop_circle_outlined, 104 | size: _iconSize, 105 | ), 106 | onTap: () { 107 | final playerBloc = BlocProvider.of(context); 108 | bool isPlaying = playerBloc.state.playerState.isPlaying; 109 | bool isPaused = playerBloc.state.playerState.isPaused; 110 | if (isPlaying || isPaused) { 111 | playerBloc.add(const PlayerBlocEvent.stop()); 112 | } 113 | }, 114 | ); 115 | } 116 | 117 | _editButton(BuildContext context) { 118 | return InkWell( 119 | child: Icon( 120 | Icons.edit, 121 | size: _iconSize - 10, 122 | ), 123 | onTap: () { 124 | final playerBloc = BlocProvider.of(context); 125 | final recordingsBloc = BlocProvider.of(context); 126 | bool isPlaying = playerBloc.state.playerState.isPlaying; 127 | bool isPaused = playerBloc.state.playerState.isPaused; 128 | if (isPlaying || isPaused) { 129 | playerBloc.add(const PlayerBlocEvent.stop()); 130 | } 131 | Navigator.of(context) 132 | .pushNamed( 133 | SoundChangerScreen.routeName, 134 | arguments: recordingsBloc.state.recordings[_index], 135 | ) 136 | .then( 137 | (_) => recordingsBloc.add( 138 | const RecordingsBlocEvent.refresh(), 139 | ), 140 | ); 141 | }, 142 | ); 143 | } 144 | 145 | _slider(BuildContext context) { 146 | final playerBloc = BlocProvider.of(context); 147 | final max = 148 | playerBloc.state.recording?.duration?.inMilliseconds.toDouble() ?? 0; 149 | double value = (playerBloc.state.position.inMilliseconds.toDouble()); 150 | if (value > max) { 151 | value = max; 152 | } 153 | return Slider( 154 | min: 0.0, 155 | max: max, 156 | value: value, 157 | onChanged: (value) => playerBloc.add(PlayerBlocEvent.seekToPosition( 158 | Duration(milliseconds: value.round()))), 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/domain/recorder/recorder_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | import 'package:voice_changer/domain/common/exception/failure.dart'; 6 | 7 | part 'recorder_service.freezed.dart'; 8 | 9 | ///The contract which provides the necessary functions of a recorder. 10 | ///To use the service without errors, you must first call [initRecorder] and when done call [disposeRecorder]. After calling dispose, 11 | /// the service instance must not be used again or otherwise unknown states might be reached. 12 | abstract class RecorderService { 13 | static const String defaultCodec = 'mp4'; 14 | 15 | ///Stream of the states of the recorder, it is seeded with value [RecorderState.uninitialized()] 16 | Stream get recorderStateStream; 17 | 18 | ///Stream of the durations of the current recording, it is seeded with value [Duration.zero] 19 | Stream get recordingDurationStream; 20 | 21 | ///Stream of the volume of the current recording at this point in time 22 | ///0 is the minimum value and 100 is the maximum. It is seeded with value 0 23 | Stream get recordingVolumeStream; 24 | 25 | ///The latest recorder state emitted 26 | RecorderState get recorderState; 27 | 28 | /// Initializes the recorder. 29 | /// It is allowed to call this method if initialization has already happened,in which case the same current state is maintained. 30 | /// Usually this method must not result in any failures. 31 | /// The current state will not be changed if a failure happens. 32 | ///

33 | ///

34 | /// Returns an [Either] wrapping: 35 | ///* [InitRecorderResult], in case of success 36 | ///* [Failure], in case of failure (no change of the state happens in this case) 37 | Future> initRecorder(); 38 | 39 | /// Disposes the recorder, and stops it if it is recording. 40 | /// It is allowed to call this method if dispose has already happened. 41 | /// Usually this method must not result in any failures. 42 | /// The current state will not be changed if a failure happens. 43 | ///

44 | ///

45 | /// Returns an [Either] wrapping: 46 | ///* nothing, in case of success 47 | ///* [Failure], in case of failure (no change of the state happens in this case) 48 | Future> disposeRecorder(); 49 | 50 | /// Starts the recorder. If it was recording or paused, then nothing happens. 51 | /// The current state will not be changed if a failure happens. 52 | ///

53 | ///

54 | ///A failure will happen if: 55 | ///* the recorder was uninitialized 56 | ///

57 | ///

58 | /// By default the codec will be inferred from the file name extension from the path 59 | /// provided to this method, but it should be a known codec (aac for example) 60 | ///

61 | ///

62 | /// - [file]: the file to record into 63 | ///

64 | ///

65 | /// Returns an [Either] wrapping: 66 | ///* nothing, in case of success 67 | ///* [Failure], in case of failure (no change of the state happens in this case) 68 | Future> startRecorder({required File file}); 69 | 70 | /// Pauses the recorder. If the recorder was not recording, then nothing happens. 71 | /// The current state will not be changed if a failure happens. 72 | ///

73 | ///

74 | ///A failure will happen if: 75 | ///* the recorder was uninitialized 76 | ///

77 | ///

78 | /// Returns an [Either] wrapping: 79 | ///* nothing, in case of success 80 | ///* [Failure], in case of failure (no change of the state happens in this case) 81 | Future> pauseRecorder(); 82 | 83 | /// Resumes the recorder. If the recorder was not paused, then nothing happens. 84 | /// The current state will not be changed if a failure happens. 85 | ///

86 | ///

87 | ///A failure will happen if: 88 | ///* the recorder was uninitialized 89 | ///

90 | ///

91 | /// Returns an [Either] wrapping: 92 | ///* nothing, in case of success 93 | ///* [Failure], in case of failure (no change of the state happens in this case) 94 | Future> resumeRecorder(); 95 | 96 | /// Stops the recorder. 97 | /// The current state will not be changed if a failure happens. 98 | ///

99 | ///

100 | ///A failure will happen if: 101 | ///* the recorder was uninitialized 102 | ///

103 | ///

104 | /// Returns an [Either] wrapping: 105 | ///* nothing, in case of success 106 | ///* [Failure], in case of failure (no change of the state happens in this case) 107 | Future> stopRecorder(); 108 | } 109 | 110 | @freezed 111 | class RecorderState with _$RecorderState { 112 | const RecorderState._(); 113 | 114 | const factory RecorderState.uninitialized() = _UninitializedRecorderState; 115 | 116 | const factory RecorderState.recording() = _RecordingRecorderState; 117 | 118 | const factory RecorderState.paused() = _PausedRecorderState; 119 | 120 | const factory RecorderState.stopped() = _StoppedRecorderState; 121 | 122 | bool get isInitialized => this is! _UninitializedRecorderState; 123 | 124 | bool get isRecording => this is _RecordingRecorderState; 125 | 126 | bool get isPaused => this is _PausedRecorderState; 127 | 128 | bool get isStopped => this is _StoppedRecorderState; 129 | } 130 | 131 | class InitRecorderResult { 132 | ///Stream of states of the recorder 133 | final Stream recorderStateStream; 134 | 135 | ///Stream of durations of the current recording 136 | final Stream recordingDurationStream; 137 | 138 | ///Stream of the volume of the current recording at this point in time 139 | ///0 is the minimum value and 100 is the maximum 140 | final Stream recordingVolumeStream; 141 | 142 | InitRecorderResult({ 143 | required this.recorderStateStream, 144 | required this.recordingDurationStream, 145 | required this.recordingVolumeStream, 146 | }); 147 | } 148 | -------------------------------------------------------------------------------- /lib/presentation/recordings_screen/bloc/player_bloc/player_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:bloc/bloc.dart'; 5 | import 'package:freezed_annotation/freezed_annotation.dart'; 6 | import 'package:injectable/injectable.dart'; 7 | import 'package:logger/logger.dart'; 8 | import 'package:voice_changer/configuration/service_locator.dart'; 9 | import 'package:voice_changer/domain/common/exception/failure.dart'; 10 | import 'package:voice_changer/domain/player/player_service.dart'; 11 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 12 | 13 | part 'player_bloc.freezed.dart'; 14 | part 'player_bloc_event.dart'; 15 | part 'player_bloc_state.dart'; 16 | 17 | @Injectable() 18 | class PlayerBloc extends Bloc { 19 | final Logger _logger = serviceLocator.get(param1: Level.debug); 20 | final PlayerService _playerService; 21 | 22 | PlayerBloc(this._playerService) : super(const PlayerBlocState()) { 23 | on( 24 | (event, emit) async { 25 | if (state.isProcessing) { 26 | _logger.w( 27 | 'an event arrived while state.isProcessing was true (will skip this event):' 28 | '\nevent is:\n$event\nstate is:\n$state\n'); 29 | return; 30 | } 31 | await event.map( 32 | init: (event) async => await _handleInitEvent(event, emit), 33 | start: (event) async => await _handleStartEvent(event, emit), 34 | pause: (event) async => await _handlePauseEvent(event, emit), 35 | resume: (event) async => await _handleResumeEvent(event, emit), 36 | stop: (event) async => await _handleStopEvent(event, emit), 37 | seekToPosition: (event) async => 38 | await _handleSeekToPositionEvent(event, emit), 39 | appGoInactive: (event) async => 40 | await _handleAppGoInactiveEvent(event, emit), 41 | ); 42 | }, 43 | ); 44 | } 45 | 46 | Future _handleInitEvent( 47 | _Init event, Emitter emit) async { 48 | emit(state.copyWith(isProcessing: true)); 49 | return (await _playerService.initPlayer()).fold( 50 | (f) async => _emitErrorState(emit, f), 51 | (initStateResult) async { 52 | emit(state.copyWith(isProcessing: false)); 53 | final futures = [ 54 | emit.forEach( 55 | initStateResult.playerStateStream, 56 | onData: (playerState) => state.copyWith( 57 | playerState: playerState, 58 | recording: playerState.isStopped ? null : state.recording, 59 | ), 60 | ), 61 | emit.forEach( 62 | initStateResult.positionStream, 63 | onData: (position) => state.copyWith( 64 | position: position, 65 | ), 66 | ), 67 | ]; 68 | return Future.wait(futures); 69 | }, 70 | ); 71 | } 72 | 73 | Future _handleStartEvent( 74 | _Start event, Emitter emit) async { 75 | emit(state.copyWith(isProcessing: true)); 76 | return (await _playerService.startPlayer(file: File(event.recording.path))) 77 | .fold( 78 | (f) async => _emitErrorState(emit, f), 79 | (_) async => emit( 80 | state.copyWith( 81 | isProcessing: false, 82 | recording: event.recording, 83 | ), 84 | ), 85 | ); 86 | } 87 | 88 | Future _handlePauseEvent( 89 | _Pause event, Emitter emit) async { 90 | emit(state.copyWith(isProcessing: true)); 91 | return (await _playerService.pausePlayer()).fold( 92 | (f) async => _emitErrorState(emit, f), 93 | (_) async => emit(state.copyWith(isProcessing: false)), 94 | ); 95 | } 96 | 97 | Future _handleResumeEvent( 98 | _Resume event, Emitter emit) async { 99 | emit(state.copyWith(isProcessing: true)); 100 | return (await _playerService.resumePlayer()).fold( 101 | (f) async => _emitErrorState(emit, f), 102 | (_) async => emit(state.copyWith(isProcessing: false)), 103 | ); 104 | } 105 | 106 | Future _handleStopEvent( 107 | _Stop event, Emitter emit) async { 108 | emit(state.copyWith(isProcessing: true)); 109 | return (await _playerService.stopPlayer()).fold( 110 | (f) async => _emitErrorState(emit, f), 111 | (_) async { 112 | emit( 113 | state.copyWith( 114 | isProcessing: false, 115 | recording: null, 116 | ), 117 | ); 118 | if (event.onDone != null) { 119 | await event.onDone!(); 120 | } 121 | }, 122 | ); 123 | } 124 | 125 | Future _handleSeekToPositionEvent( 126 | _SeekToPosition event, Emitter emit) async { 127 | // emit(state.copyWith(isProcessing: true)); 128 | return (await _playerService.seekToPosition(event.position)).fold( 129 | (f) async => _emitErrorState(emit, f), 130 | (_) async {}, 131 | // (_) async => emit(state.copyWith(isProcessing: false)), 132 | ); 133 | } 134 | 135 | Future _handleAppGoInactiveEvent( 136 | _AppGoInactiveEvent event, Emitter emit) async { 137 | if (_playerService.playerState.isPlaying || 138 | _playerService.playerState.isPaused) { 139 | emit(state.copyWith(isProcessing: true)); 140 | return (await _playerService.stopPlayer()).fold( 141 | (f) async => _emitErrorState(emit, f), 142 | (_) async => emit( 143 | state.copyWith( 144 | isProcessing: false, 145 | recording: null, 146 | ), 147 | ), 148 | ); 149 | } 150 | } 151 | 152 | void _emitErrorState(Emitter emit, Failure f) => 153 | emit(state.copyWith(isError: true, errorMessage: f.message)); 154 | 155 | @override 156 | void onEvent(event) { 157 | super.onEvent(event); 158 | _logger.d( 159 | '[PlayerBloc] event has arrived: \n$event\nwhile the state was \n$state\n'); 160 | } 161 | 162 | @override 163 | void onTransition(transition) { 164 | super.onTransition(transition); 165 | _logger.i( 166 | '[PlayerBloc] emitting a new state: \n${transition.nextState}\nin response to event \n${transition.event}\n'); 167 | } 168 | 169 | @override 170 | Future close() async { 171 | await _playerService.disposePlayer(); 172 | _logger.i('disposed player bloc'); 173 | super.close(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/widget/stop_button.dart: -------------------------------------------------------------------------------- 1 | part of 'recorder_screen.dart'; 2 | 3 | class _StopButton extends StatefulWidget { 4 | @override 5 | State<_StopButton> createState() => _StopButtonState(); 6 | } 7 | 8 | class _StopButtonState extends State<_StopButton> { 9 | late final TextEditingController _fileNameTextController; 10 | 11 | @override 12 | initState() { 13 | super.initState(); 14 | _fileNameTextController = TextEditingController(); 15 | } 16 | 17 | @override 18 | dispose() { 19 | _fileNameTextController.dispose(); 20 | super.dispose(); 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) => 25 | BlocBuilder( 26 | builder: (context, recorderBlocState) { 27 | final mq = MediaQuery.of(context); 28 | final width = mq.size.width; 29 | final containerSide = width / 5; 30 | final stopButtonOuterSide = containerSide / 2.5; 31 | final stopButtonInnerSide = stopButtonOuterSide / 1.2; 32 | 33 | final recorderBloc = BlocProvider.of(context); 34 | 35 | final currentRecordingDuration = recorderBlocState.duration.inSeconds; 36 | return SizedBox( 37 | width: containerSide, 38 | height: containerSide, 39 | child: GestureDetector( 40 | onTap: recorderBlocState.recorderState.isRecording && 41 | currentRecordingDuration >= 1 42 | ? () async { 43 | recorderBloc.add(const RecorderBlocEvent.stopRecording()); 44 | _fileNameTextController.text = 45 | recorderBlocState.recording!.name; 46 | await _showDialog(context); 47 | } 48 | : null, 49 | child: Column( 50 | mainAxisAlignment: MainAxisAlignment.end, 51 | children: [ 52 | Stack( 53 | alignment: Alignment.center, 54 | children: [ 55 | FilledRectangle( 56 | width: stopButtonOuterSide, 57 | height: stopButtonOuterSide, 58 | color: recorderBlocState.recorderState.isRecording 59 | ? Theme.of(context).colorScheme.secondary 60 | : Theme.of(context).disabledColor, 61 | ), 62 | FilledRectangle( 63 | width: stopButtonInnerSide, 64 | height: stopButtonInnerSide, 65 | color: Colors.white, 66 | ), 67 | ], 68 | ), 69 | Padding( 70 | padding: const EdgeInsets.all(2.0), 71 | child: Text('Stop', style: smallText), 72 | ), 73 | ], 74 | ), 75 | ), 76 | ); 77 | }, 78 | ); 79 | 80 | _showDialog(BuildContext context) async { 81 | final recorderBloc = BlocProvider.of(context); 82 | final mq = MediaQuery.of(context); 83 | final width = mq.size.width; 84 | final height = mq.size.height; 85 | return showDialog( 86 | context: context, 87 | builder: (ctx) => WillPopScope( 88 | onWillPop: () async => false, 89 | child: Scaffold( 90 | backgroundColor: Colors.black12, 91 | body: Center( 92 | child: Container( 93 | width: width / 1.2, 94 | height: height / 2.2, 95 | decoration: const ShapeDecoration( 96 | color: Colors.white, 97 | shape: RoundedRectangleBorder( 98 | borderRadius: BorderRadius.all(Radius.circular(20)), 99 | ), 100 | ), 101 | child: SingleChildScrollView( 102 | scrollDirection: Axis.vertical, 103 | child: Column( 104 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 105 | children: [ 106 | Padding( 107 | padding: const EdgeInsets.only(top: 14.0, bottom: 14), 108 | child: Text( 109 | 'Save Recording', 110 | style: mediumText, 111 | textAlign: TextAlign.center, 112 | ), 113 | ), 114 | const Divider(thickness: 3), 115 | const SizedBox(height: 10), 116 | Padding( 117 | padding: const EdgeInsets.symmetric( 118 | vertical: 10.0, 119 | horizontal: 30, 120 | ), 121 | child: TextField( 122 | autofocus: true, 123 | controller: _fileNameTextController, 124 | decoration: const InputDecoration( 125 | border: OutlineInputBorder( 126 | borderRadius: BorderRadius.all( 127 | Radius.circular(30), 128 | ), 129 | ), 130 | errorMaxLines: 4, 131 | ), 132 | ), 133 | ), 134 | TextButton( 135 | child: Text('Save', style: mediumText), 136 | onPressed: () { 137 | String? invalidMessage = FileExtension.isValidFileName( 138 | _fileNameTextController.text); 139 | if (invalidMessage == null) { 140 | recorderBloc.add(RecorderBlocEvent.saveRecording( 141 | newRecordingFileName: 142 | _fileNameTextController.text)); 143 | Navigator.of(context).pop(); 144 | } else { 145 | ScaffoldMessenger.of(ctx).showSnackBar( 146 | SnackBar( 147 | content: Text(invalidMessage), 148 | duration: const Duration(seconds: 1), 149 | ), 150 | ); 151 | } 152 | }, 153 | ), 154 | const SizedBox(height: 10), 155 | TextButton( 156 | child: Text('Delete', 157 | style: mediumText.copyWith(color: Colors.red)), 158 | onPressed: () { 159 | recorderBloc 160 | .add(const RecorderBlocEvent.deleteRecording()); 161 | Navigator.of(context).pop(); 162 | }, 163 | ), 164 | ], 165 | ), 166 | ), 167 | ), 168 | ), 169 | ), 170 | ), 171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /lib/presentation/recorder_screen/bloc/recorder_bloc/recorder_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:bloc/bloc.dart'; 5 | import 'package:freezed_annotation/freezed_annotation.dart'; 6 | import 'package:injectable/injectable.dart'; 7 | import 'package:logger/logger.dart'; 8 | import 'package:meta/meta.dart'; 9 | import 'package:voice_changer/configuration/service_locator.dart'; 10 | import 'package:voice_changer/domain/common/exception/failure.dart'; 11 | import 'package:voice_changer/domain/common/extensions/datetime_extensions.dart'; 12 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 13 | import 'package:voice_changer/domain/common/service/filesystem_service.dart'; 14 | import 'package:voice_changer/domain/recorder/recorder_service.dart'; 15 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 16 | 17 | part 'recorder_bloc.freezed.dart'; 18 | part 'recorder_bloc_event.dart'; 19 | part 'recorder_bloc_state.dart'; 20 | 21 | @Injectable() 22 | class RecorderBloc extends Bloc { 23 | final RecorderService _recorderService; 24 | final FileSystemService _fileSystemService; 25 | final Logger _logger = serviceLocator.get(param1: Level.debug); 26 | 27 | RecorderBloc(this._recorderService, this._fileSystemService) 28 | : super(const RecorderBlocState()) { 29 | on( 30 | (event, emit) async { 31 | await event.map( 32 | init: (event) async => await _handleInitEvent(event, emit), 33 | startRecording: (event) async => 34 | await _handleStartRecordingEvent(event, emit), 35 | stopRecording: (event) async => 36 | await _handleStopRecordingEvent(event, emit), 37 | saveRecording: (event) async => 38 | await _handleSaveRecordingEvent(event, emit), 39 | appGoInactive: (event) async => 40 | await _handleAppGoInactiveEvent(event, emit), 41 | deleteRecording: (event) async => 42 | await _handleDeleteRecordingEvent(event, emit), 43 | ); 44 | }, 45 | ); 46 | } 47 | 48 | Future _handleInitEvent( 49 | _InitEvent _, Emitter emit) async => 50 | (await _recorderService.initRecorder()).fold( 51 | (f) async => _emitErrorState(emit, f), 52 | (initRecorderResult) async { 53 | final futures = [ 54 | emit.forEach( 55 | initRecorderResult.recorderStateStream, 56 | onData: (recorderState) => 57 | state.copyWith(recorderState: recorderState), 58 | ), 59 | emit.forEach( 60 | initRecorderResult.recordingDurationStream, 61 | onData: (duration) => state.copyWith(duration: duration), 62 | ), 63 | emit.forEach( 64 | initRecorderResult.recordingVolumeStream, 65 | onData: (volume) => state.copyWith(volume: volume), 66 | ), 67 | ]; 68 | return Future.wait(futures); 69 | }, 70 | ); 71 | 72 | Future _handleStartRecordingEvent( 73 | _StartRecordingEvent event, Emitter emit) async => 74 | (await _fileSystemService.getDefaultStorageDirectory()).fold( 75 | (f) async => _emitErrorState(emit, f), 76 | (directory) async => (await _fileSystemService.createFile( 77 | fileName: 'rec_${DateTime.now().toPathSuitableString()}', 78 | extension: RecorderService.defaultCodec, 79 | path: directory.path, 80 | )) 81 | .fold( 82 | (f) async => _emitErrorState(emit, f), 83 | (file) async { 84 | return (await _recorderService.startRecorder(file: file)) 85 | .fold( 86 | (f) async => _emitErrorState(emit, f), 87 | (_) async => emit( 88 | state.copyWith( 89 | recording: RecordingDetails( 90 | name: FileExtension.getName(file.path), 91 | path: file.path, 92 | duration: null, //not interested in this field here 93 | ), 94 | ), 95 | ), 96 | ); 97 | }, 98 | ), 99 | ); 100 | 101 | Future _handleStopRecordingEvent( 102 | _StopRecordingEvent event, Emitter emit) async => 103 | (await _recorderService.stopRecorder()).fold( 104 | (f) async => _emitErrorState(emit, f), //failure in stopRecorder 105 | (_) async {}, 106 | ); 107 | 108 | Future _handleSaveRecordingEvent( 109 | _SaveRecordingEvent event, Emitter emit) async { 110 | if (event.newRecordingFileName == state.recording!.name) { 111 | emit(state.copyWith(recording: null)); 112 | return; 113 | } 114 | return (await _fileSystemService.renameFile( 115 | file: File(state.recording!.path), 116 | newFileName: event.newRecordingFileName, 117 | extension: RecorderService.defaultCodec, 118 | )) 119 | .fold( 120 | (f) async => _emitErrorState(emit, f), 121 | (_) async => emit(state.copyWith(recording: null)), 122 | ); 123 | } 124 | 125 | Future _handleDeleteRecordingEvent( 126 | _DeleteRecordingEvent event, Emitter emit) async { 127 | return (await _fileSystemService.deleteFile( 128 | File(state.recording!.path), 129 | )) 130 | .fold( 131 | (f) async => _emitErrorState(emit, f), 132 | (_) async => emit(state.copyWith(recording: null)), 133 | ); 134 | } 135 | 136 | Future _handleAppGoInactiveEvent( 137 | _AppGoInactiveEvent event, Emitter emit) async { 138 | if (state.recorderState.isRecording) { 139 | (await _recorderService.stopRecorder()).fold( 140 | (f) async => _emitErrorState(emit, f), //failure in stopRecorder 141 | (_) async { 142 | return (await _fileSystemService 143 | .deleteFile(File(state.recording!.path))) 144 | .fold( 145 | (f) async => _emitErrorState(emit, f), 146 | (_) async => emit(state.copyWith(recording: null)), 147 | ); 148 | }, 149 | ); 150 | } 151 | } 152 | 153 | void _emitErrorState(Emitter emit, Failure f) => 154 | emit(state.copyWith(isError: true, errorMessage: f.message)); 155 | 156 | @override 157 | void onEvent(event) { 158 | super.onEvent(event); 159 | _logger.d( 160 | '[RecorderBloc] event has arrived: \n$event\nwhile the state was \n$state\n'); 161 | } 162 | 163 | @override 164 | void onTransition(transition) { 165 | super.onTransition(transition); 166 | _logger.i( 167 | '[RecorderBloc] emitting a new state: \n${transition.nextState}\nin response to event \n${transition.event}\n'); 168 | } 169 | 170 | @override 171 | Future close() async { 172 | await _recorderService.disposeRecorder(); 173 | _logger.i('disposed recorder bloc'); 174 | super.close(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/domain/player/player_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:injectable/injectable.dart'; 6 | import 'package:just_audio/just_audio.dart' as just_audio; 7 | import 'package:logger/logger.dart'; 8 | import 'package:rxdart/rxdart.dart'; 9 | import 'package:voice_changer/configuration/service_locator.dart'; 10 | import 'package:voice_changer/domain/common/exception/failure.dart'; 11 | import 'package:voice_changer/domain/player/player_service.dart'; 12 | 13 | @Injectable(as: PlayerService) 14 | class PLayerServiceImpl implements PlayerService { 15 | final Logger _logger = serviceLocator.get(param1: Level.debug); 16 | final just_audio.AudioPlayer _player = just_audio.AudioPlayer(); 17 | 18 | final BehaviorSubject _playerStateSubject = 19 | BehaviorSubject.seeded(const PlayerState.uninitialized()); 20 | 21 | final BehaviorSubject _positionSubject = 22 | BehaviorSubject.seeded(Duration.zero); 23 | 24 | @override 25 | Stream get positionStream => _positionSubject.stream; 26 | 27 | @override 28 | Stream get playerStateStream => _playerStateSubject.stream; 29 | 30 | @override 31 | PlayerState get playerState => _playerStateSubject.value; 32 | 33 | @override 34 | Future> initPlayer() async { 35 | try { 36 | if (!_playerStateSubject.value.isInitialized) { 37 | late StreamSubscription subscription; 38 | subscription = _player.positionStream.listen( 39 | (duration) => _positionSubject.add(duration), 40 | onDone: () => subscription.cancel(), 41 | ); 42 | _playerStateSubject.add(const PlayerState.stopped()); 43 | } 44 | return Right( 45 | InitPlayerResult( 46 | playerStateStream: _playerStateSubject.stream, 47 | positionStream: _positionSubject.stream, 48 | ), 49 | ); 50 | } catch (e) { 51 | _logger.e('error occurred in initPlayer()', e); 52 | return Left( 53 | Failure( 54 | Failure.defaultErrorMessage, 55 | const ErrorCode.initPlayerError(), 56 | ), 57 | ); 58 | } 59 | } 60 | 61 | @override 62 | Future> disposePlayer() async { 63 | try { 64 | if (!_playerStateSubject.value.isInitialized) { 65 | return const Right(null); 66 | } 67 | await _player.dispose(); 68 | _playerStateSubject.add(const PlayerState.uninitialized()); 69 | await _playerStateSubject.close(); 70 | await _positionSubject.close(); 71 | return const Right(null); 72 | } catch (e) { 73 | _logger.e('error occurred in disposePlayer()', e); 74 | return Left( 75 | Failure( 76 | Failure.defaultErrorMessage, 77 | const ErrorCode.disposePlayerError(), 78 | ), 79 | ); 80 | } 81 | } 82 | 83 | @override 84 | Future> startPlayer({ 85 | required File file, 86 | Function? onDone, 87 | }) async { 88 | try { 89 | if (_playerStateSubject.value.isPlaying || 90 | _playerStateSubject.value.isPaused) { 91 | return const Right(null); 92 | } 93 | if (!_playerStateSubject.value.isInitialized) { 94 | throw Exception( 95 | 'startPlayback() was called from an illegal state: ${_playerStateSubject.value}'); 96 | } 97 | await _player.setFilePath(file.path); 98 | 99 | late StreamSubscription subscription; 100 | subscription = _player.processingStateStream.listen( 101 | (processingState) async { 102 | if (processingState == just_audio.ProcessingState.completed || 103 | processingState == just_audio.ProcessingState.idle) { 104 | await subscription.cancel(); 105 | _playerStateSubject.add(const PlayerState.stopped()); 106 | if (onDone != null) { 107 | await onDone(); 108 | } 109 | } 110 | }, 111 | ); 112 | _player.play(); 113 | _playerStateSubject.add(const PlayerState.playing()); 114 | return const Right(null); 115 | } catch (e) { 116 | _logger.e('error occurred in startPlayer()', e); 117 | return Left( 118 | Failure( 119 | Failure.defaultErrorMessage, 120 | const ErrorCode.startPlayerError(), 121 | ), 122 | ); 123 | } 124 | } 125 | 126 | @override 127 | Future> pausePlayer() async { 128 | try { 129 | if (!_playerStateSubject.value.isInitialized) { 130 | throw Exception( 131 | 'pausePlayback() was called from an illegal state: ${_playerStateSubject.value}'); 132 | } 133 | if (!_playerStateSubject.value.isPlaying) { 134 | return const Right(null); 135 | } 136 | await _player.pause(); 137 | _playerStateSubject.add(const PlayerState.paused()); 138 | return const Right(null); 139 | } catch (e) { 140 | _logger.e('error occurred in pausePlayer()', e); 141 | return Left( 142 | Failure( 143 | Failure.defaultErrorMessage, 144 | const ErrorCode.pausePlayerError(), 145 | ), 146 | ); 147 | } 148 | } 149 | 150 | @override 151 | Future> resumePlayer() async { 152 | try { 153 | if (!_playerStateSubject.value.isInitialized) { 154 | throw Exception( 155 | 'resumePlayback() was called from an illegal state: ${_playerStateSubject.value}'); 156 | } 157 | if (!_playerStateSubject.value.isPaused) { 158 | return const Right(null); 159 | } 160 | _player.play(); 161 | _playerStateSubject.add(const PlayerState.playing()); 162 | return const Right(null); 163 | } catch (e) { 164 | _logger.e('error occurred in resumePlayer()', e); 165 | return Left( 166 | Failure( 167 | Failure.defaultErrorMessage, 168 | const ErrorCode.resumePlayerError(), 169 | ), 170 | ); 171 | } 172 | } 173 | 174 | @override 175 | Future> stopPlayer() async { 176 | try { 177 | if (!_playerStateSubject.value.isInitialized) { 178 | throw Exception( 179 | 'stopPlayback() was called from an illegal state: ${_playerStateSubject.value}'); 180 | } 181 | if (!_playerStateSubject.value.isPlaying && 182 | !_playerStateSubject.value.isPaused) { 183 | return const Right(null); 184 | } 185 | await _player.stop(); 186 | _playerStateSubject.add(const PlayerState.stopped()); 187 | return const Right(null); 188 | } catch (e) { 189 | _logger.e('error occurred in stopPlayback()', e); 190 | return Left( 191 | Failure( 192 | Failure.defaultErrorMessage, 193 | const ErrorCode.stopPlayerError(), 194 | ), 195 | ); 196 | } 197 | } 198 | 199 | @override 200 | Future> seekToPosition(Duration position) async { 201 | try { 202 | if (!_playerStateSubject.value.isInitialized) { 203 | throw Exception( 204 | 'seekToPosition() was called from an illegal state: ${_playerStateSubject.value}'); 205 | } 206 | if (!_playerStateSubject.value.isPlaying && 207 | !_playerStateSubject.value.isPaused) { 208 | return const Right(null); 209 | } 210 | await _player.seek(position); 211 | return const Right(null); 212 | } catch (e) { 213 | _logger.e('error occurred in seekToPosition()', e); 214 | return Left( 215 | Failure( 216 | Failure.defaultErrorMessage, 217 | const ErrorCode.seekToPositionError(), 218 | ), 219 | ); 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /lib/domain/recorder/recorder_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:math'; 4 | 5 | import 'package:dartz/dartz.dart'; 6 | import 'package:flutter_sound_lite/flutter_sound.dart'; 7 | import 'package:flutter_sound_lite/public/flutter_sound_recorder.dart'; 8 | import 'package:injectable/injectable.dart'; 9 | import 'package:logger/logger.dart'; 10 | import 'package:rxdart/rxdart.dart'; 11 | import 'package:voice_changer/configuration/service_locator.dart'; 12 | import 'package:voice_changer/domain/common/exception/failure.dart'; 13 | 14 | import 'recorder_service.dart'; 15 | 16 | ///This implementation uses a third party dependency (flutter_sound_recorder) 17 | ///to implement recorder services. It also manages the state of the recorder internally. 18 | @Injectable(as: RecorderService) 19 | class RecorderServiceImpl implements RecorderService { 20 | ///The recorder class from the flutter_sound_recorder package 21 | final FlutterSoundRecorder _recorder = 22 | FlutterSoundRecorder(logLevel: Level.nothing); 23 | 24 | final Logger _logger = serviceLocator.get(param1: Level.debug); 25 | 26 | final BehaviorSubject _recorderStateSubject = 27 | BehaviorSubject.seeded(const RecorderState.uninitialized()); 28 | 29 | final BehaviorSubject _recordingDurationSubject = 30 | BehaviorSubject.seeded(Duration.zero); 31 | 32 | final BehaviorSubject _recordingVolumeSubject = 33 | BehaviorSubject.seeded(0); 34 | 35 | @override 36 | Stream get recorderStateStream => _recorderStateSubject.stream; 37 | 38 | @override 39 | Stream get recordingDurationStream => 40 | _recordingDurationSubject.stream; 41 | 42 | @override 43 | Stream get recordingVolumeStream => _recordingVolumeSubject.stream; 44 | 45 | @override 46 | RecorderState get recorderState => _recorderStateSubject.value; 47 | 48 | @override 49 | Future> initRecorder() async { 50 | try { 51 | if (_recorderStateSubject.value.isInitialized) { 52 | return Right( 53 | InitRecorderResult( 54 | recorderStateStream: _recorderStateSubject.stream, 55 | recordingDurationStream: _recordingDurationSubject.stream, 56 | recordingVolumeStream: _recordingVolumeSubject.stream, 57 | ), 58 | ); 59 | } 60 | await _recorder.openAudioSession(); 61 | await _recorder.setSubscriptionDuration(const Duration(milliseconds: 50)); 62 | 63 | late StreamSubscription subscription; 64 | subscription = _recorder.onProgress!.listen( 65 | (data) { 66 | _recordingDurationSubject.add(data.duration); 67 | if (data.decibels != null) { 68 | //emitted value from _recordingVolumeController is 10^(decibels/20) % 100 69 | _recordingVolumeSubject 70 | .add((pow(10, data.decibels! / 20) % 100).toDouble()); 71 | } 72 | }, 73 | onDone: () => subscription.cancel(), 74 | ); 75 | 76 | // if (!_recorderStateController.hasListener) { 77 | // throw Exception( 78 | // 'There must exist a listener to the recorderStateStream when calling initRecorder()'); 79 | // } 80 | _recorderStateSubject.add(const RecorderState.stopped()); 81 | _recordingVolumeSubject.add(0); 82 | _recordingDurationSubject.add(Duration.zero); 83 | return Right( 84 | InitRecorderResult( 85 | recorderStateStream: _recorderStateSubject.stream, 86 | recordingDurationStream: _recordingDurationSubject.stream, 87 | recordingVolumeStream: _recordingVolumeSubject.stream, 88 | ), 89 | ); 90 | } catch (e) { 91 | _logger.e('error occurred in initRecorder()', e); 92 | return Left( 93 | Failure( 94 | Failure.defaultErrorMessage, 95 | const ErrorCode.initRecorderError(), 96 | ), 97 | ); 98 | } 99 | } 100 | 101 | @override 102 | Future> disposeRecorder() async { 103 | if (!_recorderStateSubject.value.isInitialized) { 104 | return const Right(null); 105 | } 106 | try { 107 | await _recorder.closeAudioSession(); 108 | _recorderStateSubject.close(); 109 | _recordingDurationSubject.close(); 110 | _recordingVolumeSubject.close(); 111 | return const Right(null); 112 | } catch (e) { 113 | _logger.e('error occurred in disposeRecorder()', e); 114 | return Left( 115 | Failure( 116 | Failure.defaultErrorMessage, 117 | const ErrorCode.disposeRecorderError(), 118 | ), 119 | ); 120 | } 121 | } 122 | 123 | @override 124 | Future> startRecorder({required File file}) async { 125 | try { 126 | if (!_recorderStateSubject.value.isInitialized) { 127 | throw Exception( 128 | 'startRecorder() was called from an illegal state: ${_recorderStateSubject.value}'); 129 | } 130 | if (_recorderStateSubject.value.isRecording || 131 | _recorderStateSubject.value.isPaused) { 132 | return const Right(null); 133 | } 134 | // _logger.i('this recording will be saved into $path'); 135 | await _recorder.startRecorder( 136 | toFile: file.path, codec: Codec.defaultCodec); 137 | _recorderStateSubject.add(const RecorderState.recording()); 138 | return const Right(null); 139 | } catch (e) { 140 | _logger.e('error occurred in startRecorder()', e); 141 | return Left( 142 | Failure( 143 | Failure.defaultErrorMessage, 144 | const ErrorCode.startRecorderError(), 145 | ), 146 | ); 147 | } 148 | } 149 | 150 | @override 151 | Future> pauseRecorder() async { 152 | try { 153 | if (!_recorderStateSubject.value.isInitialized) { 154 | throw Exception( 155 | 'pauseRecorder() was called from an illegal state: ${_recorderStateSubject.value}'); 156 | } 157 | if (!_recorderStateSubject.value.isRecording) { 158 | return const Right(null); 159 | } 160 | await _recorder.pauseRecorder(); 161 | _recorderStateSubject.add(const RecorderState.paused()); 162 | //no change to recording details here 163 | return const Right(null); 164 | } catch (e) { 165 | _logger.e('error occurred in pauseRecorder()', e); 166 | return Left( 167 | Failure( 168 | Failure.defaultErrorMessage, 169 | const ErrorCode.pauseRecorderError(), 170 | ), 171 | ); 172 | } 173 | } 174 | 175 | @override 176 | Future> resumeRecorder() async { 177 | try { 178 | if (!_recorderStateSubject.value.isInitialized) { 179 | throw Exception( 180 | 'resumeRecorder() was called from an illegal state: ${_recorderStateSubject.value}'); 181 | } 182 | if (!_recorderStateSubject.value.isPaused) { 183 | return const Right(null); 184 | } 185 | await _recorder.resumeRecorder(); 186 | _recorderStateSubject.add(const RecorderState.recording()); 187 | //no change to recording details here 188 | return const Right(null); 189 | } catch (e) { 190 | _logger.e('error occurred in resumeRecorder()', e); 191 | return Left( 192 | Failure( 193 | Failure.defaultErrorMessage, 194 | const ErrorCode.resumeRecorderError(), 195 | ), 196 | ); 197 | } 198 | } 199 | 200 | @override 201 | Future> stopRecorder() async { 202 | try { 203 | if (!_recorderStateSubject.value.isInitialized) { 204 | throw Exception( 205 | 'stopRecorder() was called from an illegal state: $_recorderStateSubject.value'); 206 | } 207 | if (!_recorderStateSubject.value.isRecording && 208 | !_recorderStateSubject.value.isPaused) { 209 | return const Right(null); 210 | } 211 | await _recorder.stopRecorder(); 212 | _recorderStateSubject.add(const RecorderState.stopped()); 213 | return const Right(null); 214 | } catch (e) { 215 | _logger.e('error occurred in stopRecorder()', e); 216 | return Left( 217 | Failure( 218 | Failure.defaultErrorMessage, 219 | const ErrorCode.stopRecorderError(), 220 | ), 221 | ); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/bloc/sound_changer_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:bloc/bloc.dart'; 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | import 'package:injectable/injectable.dart'; 6 | import 'package:logger/logger.dart'; 7 | import 'package:voice_changer/configuration/service_locator.dart'; 8 | import 'package:voice_changer/domain/common/exception/failure.dart'; 9 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 10 | import 'package:voice_changer/domain/common/service/filesystem_service.dart'; 11 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 12 | import 'package:voice_changer/domain/sound_changer/sound_changer_service.dart'; 13 | 14 | part 'sound_changer_bloc.freezed.dart'; 15 | 16 | part 'sound_changer_bloc_event.dart'; 17 | 18 | part 'sound_changer_bloc_state.dart'; 19 | 20 | @Injectable() 21 | class SoundChangerBloc 22 | extends Bloc { 23 | final Logger _logger = serviceLocator.get(param1: Level.debug); 24 | final SoundChangerService _soundChangerService; 25 | final FileSystemService _fileSystemService; 26 | 27 | SoundChangerBloc(this._soundChangerService, this._fileSystemService) 28 | : super(const SoundChangerBlocState()) { 29 | on( 30 | (event, emit) async { 31 | await event.map( 32 | init: (event) async => await _init(event, emit), 33 | applyEffects: (event) async => await _applyEffects(event, emit), 34 | //tempo 35 | shouldChangeTempoChanged: (event) async => 36 | await _shouldChangeTempoChanged(event, emit), 37 | tempoChanged: (event) async => await _tempoChanged(event, emit), 38 | //echo 39 | shouldAddEchoChanged: (event) async => 40 | await _shouldAddEchoChanged(event, emit), 41 | //trim 42 | shouldTrimChanged: (event) async => 43 | await _shouldTrimChanged(event, emit), 44 | trimStartChanged: (event) async => 45 | await _trimStartChanged(event, emit), 46 | trimEndChanged: (event) async => await _trimEndChanged(event, emit), 47 | //sample rate 48 | shouldChangeSampleRateChanged: (event) async => 49 | await _shouldChangeSampleRateChanged(event, emit), 50 | sampleRateChanged: (event) async => 51 | await _sampleRateChanged(event, emit), 52 | //volume 53 | shouldChangeVolumeChanged: (event) async => 54 | await _shouldChangeVolumeChanged(event, emit), 55 | volumeChanged: (event) async => await _volumeChanged(event, emit), 56 | //reverse 57 | shouldReverseChanged: (event) async => 58 | await _shouldReverseChanged(event, emit), 59 | ); 60 | }, 61 | ); 62 | } 63 | 64 | Future _init(_Init event, Emitter emit) async { 65 | emit(state.copyWith( 66 | isInitialized: true, 67 | recording: event.recording, 68 | trimEnd: event.recording.duration?.inSeconds ?? 1, 69 | )); 70 | } 71 | 72 | _shouldChangeTempoChanged(_ShouldChangeTempoChanged event, 73 | Emitter emit) async => 74 | emit(state.copyWith(shouldChangeTempo: event.newValue)); 75 | 76 | _tempoChanged( 77 | _TempoChanged event, Emitter emit) async => 78 | emit(state.copyWith(tempo: event.newValue)); 79 | 80 | _shouldAddEchoChanged( 81 | _ShouldAddEcho event, Emitter emit) async => 82 | emit(state.copyWith(shouldAddEcho: event.newValue)); 83 | 84 | _shouldTrimChanged(_ShouldTrimChanged event, 85 | Emitter emit) async => 86 | emit(state.copyWith(shouldTrim: event.newValue)); 87 | 88 | _trimStartChanged( 89 | _TrimStartChanged event, Emitter emit) async => 90 | emit(state.copyWith(trimStart: event.newValue)); 91 | 92 | _trimEndChanged( 93 | _TrimEndChanged event, Emitter emit) async => 94 | emit(state.copyWith(trimEnd: event.newValue)); 95 | 96 | _shouldChangeSampleRateChanged(_ShouldChangeSampleRateChanged event, 97 | Emitter emit) async => 98 | emit(state.copyWith(shouldChangeSampleRate: event.newValue)); 99 | 100 | _sampleRateChanged(_SampleRateChanged event, 101 | Emitter emit) async => 102 | emit(state.copyWith(sampleRate: event.newValue)); 103 | 104 | _shouldChangeVolumeChanged(_ShouldChangeVolumeChanged event, 105 | Emitter emit) async => 106 | emit(state.copyWith(shouldChangeVolume: event.newValue)); 107 | 108 | _volumeChanged( 109 | _VolumeChanged event, Emitter emit) async => 110 | emit(state.copyWith(volume: event.newValue)); 111 | 112 | _shouldReverseChanged(_ShouldReverseChanged event, 113 | Emitter emit) async => 114 | emit(state.copyWith(shouldReverse: event.newValue)); 115 | 116 | Future _applyEffects( 117 | _ApplyEffects event, Emitter emit) async { 118 | emit(state.copyWith(isProcessing: true)); 119 | return (await _fileSystemService.getDefaultStorageDirectory()).fold( 120 | (f) async => _emitErrorState(emit, f), 121 | (defaultStorageDirectory) async { 122 | File outputFile = File( 123 | '${defaultStorageDirectory.path}/${event.outputFileName}.${FileExtension.getExtension(state.recording!.path)}'); 124 | List failures = []; 125 | if (state.shouldChangeTempo) { 126 | await _applyChangeTempo( 127 | File(state.recording!.path), outputFile, state.tempo, failures); 128 | } 129 | if (state.shouldAddEcho) { 130 | await _applyAddEcho( 131 | File(state.recording!.path), 132 | outputFile, 133 | state.echoInputGain, 134 | state.echoOutputGain, 135 | [state.echoDelay], 136 | [state.echoDecay], 137 | failures); 138 | } 139 | if (state.shouldTrim) { 140 | await _applyTrim( 141 | File(state.recording!.path), 142 | outputFile, 143 | state.trimStart, 144 | state.trimEnd, 145 | failures, 146 | ); 147 | } 148 | if (state.shouldChangeSampleRate) { 149 | await _applySetSampleRate( 150 | File(state.recording!.path), 151 | outputFile, 152 | state.sampleRate, 153 | failures, 154 | ); 155 | } 156 | if (state.shouldChangeVolume) { 157 | await _applySetVolume( 158 | File(state.recording!.path), 159 | outputFile, 160 | state.volume, 161 | failures, 162 | ); 163 | } 164 | if (state.shouldReverse) { 165 | await _applyReverse( 166 | File(state.recording!.path), outputFile, failures); 167 | } 168 | emit(state.copyWith(isProcessing: false)); 169 | if (failures.isNotEmpty) { 170 | emit( 171 | state.copyWith( 172 | isError: true, 173 | errorMessage: 'error(s) occurred while applying sound effects'), 174 | ); 175 | _logger.e( 176 | 'error(s) occurred while applying sound effects, failures are $failures'); 177 | } 178 | }, 179 | ); 180 | } 181 | 182 | Future _applyReverse( 183 | File inputFile, outputFile, List failures) async { 184 | (await _soundChangerService.reverseAudio( 185 | inputFile: inputFile, 186 | outputFile: outputFile, 187 | )) 188 | .fold( 189 | (f) async => failures.add(f), 190 | (_) async {}, 191 | ); 192 | } 193 | 194 | Future _applySetVolume(File inputFile, File outputFile, double volume, 195 | List failures) async { 196 | (await _soundChangerService.setVolume( 197 | inputFile: inputFile, 198 | outputFile: outputFile, 199 | volume: volume, 200 | )) 201 | .fold( 202 | (f) async => failures.add(f), 203 | (_) async {}, 204 | ); 205 | } 206 | 207 | Future _applySetSampleRate(File inputFile, File outputFile, 208 | int sampleRate, List failures) async { 209 | (await _soundChangerService.setSampleRate( 210 | inputFile: inputFile, 211 | outputFile: outputFile, 212 | sampleRate: sampleRate, 213 | )) 214 | .fold( 215 | (f) async => failures.add(f), 216 | (_) async {}, 217 | ); 218 | } 219 | 220 | Future _applyTrim(File inputFile, File outputFile, int start, int end, 221 | List failures) async { 222 | (await _soundChangerService.trimSound( 223 | inputFile: inputFile, 224 | outputFile: outputFile, 225 | start: start, 226 | end: end, 227 | )) 228 | .fold( 229 | (f) async => failures.add(f), 230 | (_) async {}, 231 | ); 232 | } 233 | 234 | Future _applyAddEcho( 235 | File inputFile, 236 | File outputFile, 237 | double inputGain, 238 | double outputGain, 239 | List delays, 240 | List decays, 241 | List failures) async { 242 | (await _soundChangerService.addEcho( 243 | inputFile: inputFile, 244 | outputFile: outputFile, 245 | inputGain: inputGain, 246 | outputGain: outputGain, 247 | delays: delays, 248 | decays: decays, 249 | )) 250 | .fold( 251 | (f) async => failures.add(f), 252 | (_) async {}, 253 | ); 254 | } 255 | 256 | Future _applyChangeTempo(File inputFile, File outputFile, double tempo, 257 | List failures) async { 258 | (await _soundChangerService.changeTempo( 259 | inputFile: inputFile, 260 | outputFile: outputFile, 261 | tempo: tempo, 262 | )) 263 | .fold( 264 | (f) async => failures.add(f), 265 | (_) async {}, 266 | ); 267 | } 268 | 269 | void _emitErrorState(Emitter emit, Failure f) => 270 | emit(state.copyWith(isError: true, errorMessage: f.message)); 271 | 272 | @override 273 | void onEvent(event) { 274 | super.onEvent(event); 275 | _logger.d( 276 | '[SoundChangerBloc] event has arrived: \n$event\nwhile the state was \n$state\n'); 277 | } 278 | 279 | @override 280 | void onTransition(transition) { 281 | super.onTransition(transition); 282 | _logger.i( 283 | '[SoundChangerBloc] emitting a new state: \n${transition.nextState}\nin response to event \n${transition.event}\n'); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /lib/presentation/sound_changer_screen/widget/sound_changer_options.dart: -------------------------------------------------------------------------------- 1 | part of 'sound_changer_screen.dart'; 2 | 3 | class _SoundChangerOptions extends StatefulWidget { 4 | const _SoundChangerOptions(); 5 | 6 | @override 7 | State<_SoundChangerOptions> createState() => _SoundChangerOptionsState(); 8 | } 9 | 10 | class _SoundChangerOptionsState extends State<_SoundChangerOptions> { 11 | late final TextEditingController _fileNameController; 12 | 13 | @override 14 | void initState() { 15 | super.initState(); 16 | final soundChangerBlocState = 17 | BlocProvider.of(context, listen: false).state; 18 | _fileNameController = 19 | TextEditingController(text: soundChangerBlocState.recording!.name); 20 | } 21 | 22 | @override 23 | void dispose() { 24 | _fileNameController.dispose(); 25 | super.dispose(); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) => 30 | BlocBuilder( 31 | builder: (context, soundChangerBlocState) { 32 | final soundChangerBloc = BlocProvider.of(context); 33 | 34 | bool isProcessing = soundChangerBlocState.isProcessing; 35 | return Column( 36 | children: [ 37 | Padding( 38 | padding: const EdgeInsets.all(8.0), 39 | child: Text( 40 | '\nfile: ${soundChangerBlocState.recording?.name}\n\n' 41 | 'duration: ${soundChangerBlocState.recording?.duration}', 42 | style: mediumText, 43 | ), 44 | ), 45 | const SizedBox(height: 10), 46 | const Divider(thickness: 3), 47 | Expanded( 48 | child: ListView( 49 | shrinkWrap: true, 50 | children: [ 51 | _tempoSwitchListTile( 52 | soundChangerBlocState, soundChangerBloc), 53 | const Divider(thickness: 3), 54 | _echoSwitchListTile( 55 | soundChangerBlocState, soundChangerBloc), 56 | const Divider(thickness: 3), 57 | _trimSwitchListTile( 58 | soundChangerBlocState, soundChangerBloc), 59 | const Divider(thickness: 3), 60 | _sampleRateSwitchListTile( 61 | soundChangerBlocState, soundChangerBloc), 62 | const Divider(thickness: 3), 63 | _volumeSwitchListTile( 64 | soundChangerBlocState, soundChangerBloc), 65 | const Divider(thickness: 3), 66 | _reverseSwitchListTile( 67 | soundChangerBlocState, soundChangerBloc), 68 | const Divider(thickness: 3), 69 | _fileNameListTile(), 70 | Row( 71 | mainAxisAlignment: MainAxisAlignment.center, 72 | children: [ 73 | _submitButton(isProcessing, soundChangerBloc), 74 | ], 75 | ), 76 | ], 77 | ), 78 | ), 79 | ], 80 | ); 81 | }, 82 | ); 83 | 84 | SwitchListTile _trimSwitchListTile( 85 | SoundChangerBlocState soundChangerBlocState, 86 | SoundChangerBloc soundChangerBloc) => 87 | SwitchListTile( 88 | title: Row( 89 | children: [ 90 | const Icon(Icons.cut), 91 | const SizedBox(width: 10), 92 | Text( 93 | 'Trim : ${soundChangerBlocState.trimStart} sec => ${soundChangerBlocState.trimEnd} sec', 94 | style: mediumText), 95 | ], 96 | ), 97 | subtitle: RangeSlider( 98 | min: 0.0, 99 | max: 100 | soundChangerBlocState.recording!.duration?.inSeconds.toDouble() ?? 101 | 0, 102 | divisions: soundChangerBlocState.recording!.duration?.inSeconds ?? 0, 103 | values: RangeValues(soundChangerBlocState.trimStart.toDouble(), 104 | soundChangerBlocState.trimEnd.toDouble()), 105 | onChanged: !soundChangerBlocState.shouldTrim 106 | ? null 107 | : (rangeValues) { 108 | if (rangeValues.start < rangeValues.end) { 109 | soundChangerBloc.add( 110 | SoundChangerBlocEvent.trimStartChanged( 111 | rangeValues.start.round()), 112 | ); 113 | soundChangerBloc.add( 114 | SoundChangerBlocEvent.trimEndChanged( 115 | rangeValues.end.round()), 116 | ); 117 | } 118 | }, 119 | ), 120 | value: soundChangerBlocState.shouldTrim, 121 | onChanged: (newValue) => soundChangerBloc.add( 122 | SoundChangerBlocEvent.shouldTrimChanged(newValue), 123 | ), 124 | contentPadding: const EdgeInsets.all(8.0), 125 | ); 126 | 127 | SwitchListTile _echoSwitchListTile( 128 | SoundChangerBlocState soundChangerBlocState, 129 | SoundChangerBloc soundChangerBloc) => 130 | SwitchListTile( 131 | title: Row( 132 | children: [ 133 | const Icon(Icons.surround_sound_outlined), 134 | const SizedBox(width: 10), 135 | Text('Add Echo', style: mediumText), 136 | ], 137 | ), 138 | value: soundChangerBlocState.shouldAddEcho, 139 | onChanged: (newValue) => soundChangerBloc.add( 140 | SoundChangerBlocEvent.shouldAddEchoChanged(newValue), 141 | ), 142 | contentPadding: const EdgeInsets.all(8.0), 143 | ); 144 | 145 | _tempoSwitchListTile(SoundChangerBlocState soundChangerBlocState, 146 | SoundChangerBloc soundChangerBloc) => 147 | SwitchListTile( 148 | title: Row( 149 | children: [ 150 | const Icon(Icons.speed_outlined), 151 | const SizedBox(width: 10), 152 | Text('Tempo : ${soundChangerBlocState.tempo.toStringAsFixed(1)}', 153 | style: mediumText), 154 | ], 155 | ), 156 | subtitle: Slider( 157 | min: 0.5, 158 | max: 2.0, 159 | value: soundChangerBlocState.tempo, 160 | onChanged: !soundChangerBlocState.shouldChangeTempo 161 | ? null 162 | : (newValue) => soundChangerBloc.add( 163 | SoundChangerBlocEvent.tempoChanged(newValue), 164 | ), 165 | ), 166 | value: soundChangerBlocState.shouldChangeTempo, 167 | onChanged: (newValue) => soundChangerBloc.add( 168 | SoundChangerBlocEvent.shouldChangeTempoChanged(newValue), 169 | ), 170 | contentPadding: const EdgeInsets.all(8.0), 171 | ); 172 | 173 | InputDecoration _roundTextFieldDecoration(String hint) => InputDecoration( 174 | constraints: BoxConstraints.tight(Size( 175 | MediaQuery.of(context).size.width / 1.5, 176 | MediaQuery.of(context).size.height / 15)), 177 | hintText: hint, 178 | hintMaxLines: 2, 179 | border: const OutlineInputBorder( 180 | borderRadius: BorderRadius.all( 181 | Radius.circular(10), 182 | ), 183 | ), 184 | ); 185 | 186 | _sampleRateSwitchListTile(SoundChangerBlocState soundChangerBlocState, 187 | SoundChangerBloc soundChangerBloc) => 188 | SwitchListTile( 189 | title: Row( 190 | children: [ 191 | const Icon(Icons.waves), 192 | const SizedBox(width: 10), 193 | Text('sampleRate : ${soundChangerBlocState.sampleRate}', 194 | style: mediumText), 195 | ], 196 | ), 197 | subtitle: Slider( 198 | min: 8000, 199 | max: 120000, 200 | value: soundChangerBlocState.sampleRate.toDouble(), 201 | onChanged: !soundChangerBlocState.shouldChangeSampleRate 202 | ? null 203 | : (newValue) => soundChangerBloc.add( 204 | SoundChangerBlocEvent.sampleRateChanged(newValue.round()), 205 | ), 206 | ), 207 | value: soundChangerBlocState.shouldChangeSampleRate, 208 | onChanged: (newValue) => soundChangerBloc.add( 209 | SoundChangerBlocEvent.shouldChangeSampleRateChanged(newValue), 210 | ), 211 | contentPadding: const EdgeInsets.all(8.0), 212 | ); 213 | 214 | _volumeSwitchListTile(SoundChangerBlocState soundChangerBlocState, 215 | SoundChangerBloc soundChangerBloc) => 216 | SwitchListTile( 217 | title: Row( 218 | children: [ 219 | const Icon(Icons.volume_down), 220 | const SizedBox(width: 10), 221 | Text('volume : ${soundChangerBlocState.volume.toStringAsFixed(0)}', 222 | style: mediumText), 223 | ], 224 | ), 225 | subtitle: Slider( 226 | min: 0.0, 227 | max: 100.0, 228 | value: soundChangerBlocState.volume, 229 | onChanged: !soundChangerBlocState.shouldChangeVolume 230 | ? null 231 | : (newValue) => soundChangerBloc.add( 232 | SoundChangerBlocEvent.volumeChanged(newValue), 233 | ), 234 | ), 235 | value: soundChangerBlocState.shouldChangeVolume, 236 | onChanged: (newValue) => soundChangerBloc.add( 237 | SoundChangerBlocEvent.shouldChangeVolumeChanged(newValue), 238 | ), 239 | contentPadding: const EdgeInsets.all(8.0), 240 | ); 241 | 242 | _reverseSwitchListTile(SoundChangerBlocState soundChangerBlocState, 243 | SoundChangerBloc soundChangerBloc) => 244 | SwitchListTile( 245 | title: Row( 246 | children: [ 247 | const Icon(Icons.switch_left_outlined), 248 | const SizedBox(width: 10), 249 | Text('Reverse sound', style: mediumText), 250 | ], 251 | ), 252 | value: soundChangerBlocState.shouldReverse, 253 | onChanged: (newValue) => soundChangerBloc.add( 254 | SoundChangerBlocEvent.shouldReverseChanged(newValue), 255 | ), 256 | contentPadding: const EdgeInsets.all(8.0), 257 | ); 258 | 259 | _fileNameListTile() => ListTile( 260 | title: Column( 261 | children: [ 262 | Text('save to file', style: mediumText), 263 | const SizedBox(height: 10), 264 | TextField( 265 | controller: _fileNameController, 266 | decoration: _roundTextFieldDecoration('enter output file name'), 267 | ), 268 | ], 269 | ), 270 | ); 271 | 272 | _submitButton(bool isProcessing, SoundChangerBloc soundChangerBloc) => 273 | isProcessing 274 | ? const CircularProgressIndicator() 275 | : ElevatedButton( 276 | child: Text('apply', style: largeText), 277 | onPressed: () { 278 | String? invalidMessage = 279 | FileExtension.isValidFileName(_fileNameController.text); 280 | if (invalidMessage == null) { 281 | soundChangerBloc.add( 282 | SoundChangerBlocEvent.applyEffects( 283 | _fileNameController.text, 284 | ), 285 | ); 286 | 287 | } else { 288 | ScaffoldMessenger.of(context).showSnackBar( 289 | SnackBar( 290 | content: Text(invalidMessage), 291 | duration: const Duration(seconds: 1), 292 | ), 293 | ); 294 | } 295 | }, 296 | ); 297 | } 298 | -------------------------------------------------------------------------------- /lib/domain/sound_changer/sound_changer_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:flutter_ffmpeg/flutter_ffmpeg.dart'; 5 | import 'package:injectable/injectable.dart'; 6 | import 'package:logger/logger.dart'; 7 | import 'package:voice_changer/configuration/service_locator.dart'; 8 | import 'package:voice_changer/domain/common/exception/failure.dart'; 9 | import 'package:voice_changer/domain/common/extensions/datetime_extensions.dart'; 10 | import 'package:voice_changer/domain/common/extensions/file_extensions.dart'; 11 | import 'package:voice_changer/domain/common/service/filesystem_service.dart'; 12 | import 'package:voice_changer/domain/recording_details/recording_details_service.dart'; 13 | import 'package:voice_changer/domain/sound_changer/sound_changer_service.dart'; 14 | 15 | @Injectable(as: SoundChangerService) 16 | class SoundChangerServiceImpl implements SoundChangerService { 17 | final Logger _logger = serviceLocator.get(param1: Level.debug); 18 | final FileSystemService _fileSystemService = 19 | serviceLocator.get(); 20 | final FlutterFFmpeg _ffmpeg = FlutterFFmpeg(); 21 | final RecordingDetailsService _recordingDetailsService = 22 | serviceLocator.get(); 23 | 24 | @override 25 | Future> addEcho({ 26 | required File inputFile, 27 | required File outputFile, 28 | required double inputGain, 29 | required double outputGain, 30 | required List delays, 31 | required List decays, 32 | }) async { 33 | try { 34 | if (inputGain < 0 || inputGain > 1 || outputGain < 0 || outputGain > 1) { 35 | throw Exception( 36 | 'input gain and output gain must be in the range [0,1]'); 37 | } 38 | for (final delay in delays) { 39 | if (delay < 0 || delay > 90000) { 40 | throw Exception('delay must be an int in range [0,90000]'); 41 | } 42 | } 43 | for (final decay in decays) { 44 | if (decay < 0 || decay > 1) { 45 | throw Exception('decay must be a double in range [0,1]'); 46 | } 47 | } 48 | if (delays.length != decays.length) { 49 | throw Exception('delays and decays must have same lengths'); 50 | } 51 | if (delays.isEmpty || decays.isEmpty) { 52 | throw Exception('delays and decays must not be empty'); 53 | } 54 | 55 | int changeEchoResult = 0; 56 | int moveDataResult = 0; 57 | String delaysParams = ''; 58 | String decaysParams = ''; 59 | for (int i = 0; i < delays.length; i++) { 60 | delaysParams += '${delays[i]}${i == delays.length - 1 ? '' : '|'}'; 61 | } 62 | for (int i = 0; i < decays.length; i++) { 63 | decaysParams += '${decays[i]}${i == decays.length - 1 ? '' : '|'}'; 64 | } 65 | String echoCommandParams = 66 | '$inputGain:$outputGain:$delaysParams:$decaysParams'; 67 | if (inputFile.path != outputFile.path) { 68 | //write directly into the output file 69 | changeEchoResult = await _ffmpeg.execute( 70 | '-y -i ${inputFile.path} -af \'aecho=$echoCommandParams\' ${outputFile.path}'); 71 | } else { 72 | //write into a temp file first then move the data to the output file 73 | File tempFile = await _createTempFile(outputFile); 74 | changeEchoResult = await _ffmpeg.execute( 75 | '-i ${inputFile.path} -af \'aecho=$echoCommandParams\' ${tempFile.path}'); 76 | moveDataResult = 77 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 78 | await tempFile.delete(); 79 | } 80 | if (changeEchoResult != 0 || moveDataResult != 0) { 81 | throw Exception('add echo command did not execute successfully'); 82 | } 83 | return const Right(null); 84 | } catch (e) { 85 | _logger.e('error occurred in addEcho', e); 86 | return Left( 87 | Failure( 88 | Failure.defaultErrorMessage, 89 | const ErrorCode.addEchoError(), 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | @override 96 | Future> changeTempo({ 97 | required File inputFile, 98 | required File outputFile, 99 | required double tempo, 100 | }) async { 101 | try { 102 | if (tempo < 0.5 || tempo > 100) { 103 | throw Exception('tempo value must be between 0.5 and 100'); 104 | } 105 | int changeTempoResult = 0; 106 | int moveDataResult = 0; 107 | if (inputFile.path != outputFile.path) { 108 | //write directly into the output file 109 | changeTempoResult = await _ffmpeg.execute( 110 | '-y -i ${inputFile.path} -af \'atempo=$tempo\' ${outputFile.path}'); 111 | } else { 112 | //write into a temp file first then move the data to outputFilePath 113 | File tempFile = await _createTempFile(outputFile); 114 | changeTempoResult = await _ffmpeg.execute( 115 | '-i ${inputFile.path} -af \'atempo=$tempo\' ${tempFile.path}'); 116 | moveDataResult = 117 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 118 | await tempFile.delete(); 119 | } 120 | if (changeTempoResult != 0 || moveDataResult != 0) { 121 | throw Exception('change tempo command did not execute successfully'); 122 | } 123 | return const Right(null); 124 | } catch (e) { 125 | _logger.e('error occurred in changeTempo', e); 126 | return Left( 127 | Failure( 128 | Failure.defaultErrorMessage, 129 | const ErrorCode.changeTempoError(), 130 | ), 131 | ); 132 | } 133 | } 134 | 135 | @override 136 | Future> reverseAudio({ 137 | required File inputFile, 138 | required File outputFile, 139 | }) async { 140 | try { 141 | int reverseResult = 0; 142 | int moveDataResult = 0; 143 | if (inputFile.path != outputFile.path) { 144 | //write directly into the output file 145 | reverseResult = await _ffmpeg.execute( 146 | '-y -i ${inputFile.path} -af \'areverse\' ${outputFile.path}'); 147 | } else { 148 | //write into a temp file first then move the data to outputFilePath 149 | File tempFile = await _createTempFile(outputFile); 150 | reverseResult = await _ffmpeg 151 | .execute('-i ${inputFile.path} -af \'areverse\' ${tempFile.path}'); 152 | moveDataResult = 153 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 154 | await tempFile.delete(); 155 | } 156 | if (reverseResult != 0 || moveDataResult != 0) { 157 | throw Exception('reverse audio command did not execute successfully'); 158 | } 159 | return const Right(null); 160 | } catch (e) { 161 | _logger.e('error occurred in reverseAudio', e); 162 | return Left( 163 | Failure( 164 | Failure.defaultErrorMessage, 165 | const ErrorCode.reverseAudioError(), 166 | ), 167 | ); 168 | } 169 | } 170 | 171 | @override 172 | Future> setSampleRate({ 173 | required File inputFile, 174 | required File outputFile, 175 | required int sampleRate, 176 | }) async { 177 | try { 178 | if (sampleRate < 8000 || sampleRate > 120000) { 179 | throw Exception('sample rate must be between 8000 and 120000'); 180 | } 181 | int setRateResult = 0; 182 | int moveDataResult = 0; 183 | if (inputFile.path != outputFile.path) { 184 | //write directly into the output file 185 | setRateResult = await _ffmpeg.execute( 186 | '-y -i ${inputFile.path} -af \'asetrate=$sampleRate\' ${outputFile.path}'); 187 | } else { 188 | //write into a temp file first then move the data to outputFilePath 189 | File tempFile = await _createTempFile(outputFile); 190 | setRateResult = await _ffmpeg.execute( 191 | '-i ${inputFile.path} -af \'asetrate=$sampleRate\' ${tempFile.path}'); 192 | moveDataResult = 193 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 194 | await tempFile.delete(); 195 | } 196 | if (setRateResult != 0 || moveDataResult != 0) { 197 | throw Exception('set sample rate command did not execute successfully'); 198 | } 199 | return const Right(null); 200 | } catch (e) { 201 | _logger.e('error occurred in setSampleRate', e); 202 | return Left( 203 | Failure( 204 | Failure.defaultErrorMessage, 205 | const ErrorCode.setSampleRateError(), 206 | ), 207 | ); 208 | } 209 | } 210 | 211 | @override 212 | Future> setVolume({ 213 | required File inputFile, 214 | required File outputFile, 215 | required double volume, 216 | }) async { 217 | try { 218 | if (volume < 0 || volume > 100) { 219 | throw Exception('volume must be between 0 and 100'); 220 | } 221 | int changeVolumeResult = 0; 222 | int moveDataResult = 0; 223 | if (inputFile.path != outputFile.path) { 224 | //write directly into the output file 225 | changeVolumeResult = await _ffmpeg.execute( 226 | '-y -i ${inputFile.path} -af \'volume=$volume\' ${outputFile.path}'); 227 | } else { 228 | //write into a temp file first then move the data to outputFilePath 229 | File tempFile = await _createTempFile(outputFile); 230 | changeVolumeResult = await _ffmpeg.execute( 231 | '-i ${inputFile.path} -af \'volume=$volume\' ${tempFile.path}'); 232 | moveDataResult = 233 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 234 | await tempFile.delete(); 235 | } 236 | if (changeVolumeResult != 0 || moveDataResult != 0) { 237 | throw Exception('change volume command did not execute successfully'); 238 | } 239 | return const Right(null); 240 | } catch (e) { 241 | _logger.e('error occurred in setVolume', e); 242 | return Left( 243 | Failure( 244 | Failure.defaultErrorMessage, 245 | const ErrorCode.setVolumeError(), 246 | ), 247 | ); 248 | } 249 | } 250 | 251 | @override 252 | Future> trimSound({ 253 | required File inputFile, 254 | required File outputFile, 255 | required int start, 256 | required int end, 257 | }) async { 258 | try { 259 | if (start >= end) { 260 | throw Exception('start must be < end'); 261 | } 262 | Duration? audioDuration = 263 | await (await _recordingDetailsService.getRecordingDetails(inputFile)) 264 | .fold( 265 | (f) => throw f, 266 | (recordingDetails) => recordingDetails.duration, 267 | ); 268 | if (audioDuration == null) { 269 | throw Exception('couldn\'t get duration of the audio'); 270 | } 271 | if (start < 0 || end < 0 || end > audioDuration.inSeconds) { 272 | throw Exception( 273 | 'start < 0, end < 0, end < audio duration constraints were not fully satisfied'); 274 | } 275 | int trimResult = 0; 276 | int moveDataResult = 0; 277 | if (inputFile.path != outputFile.path) { 278 | //write directly into the output file 279 | trimResult = await _ffmpeg.execute( 280 | '-y -i ${inputFile.path} -af \'atrim=$start:$end\' ${outputFile.path}'); 281 | } else { 282 | //write into a temp file first then move the data to outputFilePath 283 | File tempFile = await _createTempFile(outputFile); 284 | trimResult = await _ffmpeg.execute( 285 | '-i ${inputFile.path} -af \'atrim=$start:$end\' ${tempFile.path}'); 286 | moveDataResult = 287 | await _ffmpeg.execute('-y -i ${tempFile.path} ${outputFile.path}'); 288 | await tempFile.delete(); 289 | } 290 | if (trimResult != 0 || moveDataResult != 0) { 291 | throw Exception('trim command did not execute successfully'); 292 | } 293 | return const Right(null); 294 | } catch (e) { 295 | _logger.e('error occurred in trimSound', e); 296 | return Left( 297 | Failure( 298 | Failure.defaultErrorMessage, 299 | const ErrorCode.trimSoundError(), 300 | ), 301 | ); 302 | } 303 | } 304 | 305 | Future _createTempFile(File outputFile) async { 306 | return await ((await _fileSystemService.getDefaultStorageDirectory()).fold( 307 | (f) => throw f, 308 | (defaultDirectory) async => (await _fileSystemService.createFile( 309 | fileName: 'tmp${DateTime.now().toPathSuitableString()}', 310 | extension: FileExtension.getExtension(outputFile.path), 311 | path: defaultDirectory.path, 312 | )) 313 | .fold( 314 | (f) => throw f, 315 | id, 316 | ), 317 | )); 318 | } 319 | } 320 | --------------------------------------------------------------------------------