├── 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 | - 
23 | - 
24 | - 
25 | - 
26 | - 
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 |
--------------------------------------------------------------------------------