@siteed/expo-audio-stream 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +2 -0
- package/README.md +85 -0
- package/android/.gradle/8.1.1/checksums/checksums.lock +0 -0
- package/android/.gradle/8.1.1/dependencies-accessors/dependencies-accessors.lock +0 -0
- package/android/.gradle/8.1.1/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.1.1/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.1.1/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.1.1/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +92 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +318 -0
- package/app.plugin.js +1 -0
- package/build/ExpoAudioStream.types.d.ts +23 -0
- package/build/ExpoAudioStream.types.d.ts.map +1 -0
- package/build/ExpoAudioStream.types.js +3 -0
- package/build/ExpoAudioStream.types.js.map +1 -0
- package/build/ExpoAudioStreamModule.d.ts +3 -0
- package/build/ExpoAudioStreamModule.d.ts.map +1 -0
- package/build/ExpoAudioStreamModule.js +5 -0
- package/build/ExpoAudioStreamModule.js.map +1 -0
- package/build/ExpoAudioStreamModule.web.d.ts +35 -0
- package/build/ExpoAudioStreamModule.web.d.ts.map +1 -0
- package/build/ExpoAudioStreamModule.web.js +143 -0
- package/build/ExpoAudioStreamModule.web.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +21 -0
- package/build/index.js.map +1 -0
- package/build/useAudioRecording.d.ts +15 -0
- package/build/useAudioRecording.d.ts.map +1 -0
- package/build/useAudioRecording.js +118 -0
- package/build/useAudioRecording.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioStreamManager.swift +168 -0
- package/ios/ExpoAudioStream.podspec +27 -0
- package/ios/ExpoAudioStreamModule.swift +133 -0
- package/package.json +61 -0
- package/plugin/build/index.d.ts +5 -0
- package/plugin/build/index.js +17 -0
- package/plugin/src/index.ts +27 -0
- package/plugin/tsconfig.json +9 -0
- package/release-it.js +18 -0
- package/src/ExpoAudioStream.types.ts +25 -0
- package/src/ExpoAudioStreamModule.ts +5 -0
- package/src/ExpoAudioStreamModule.web.ts +162 -0
- package/src/index.ts +29 -0
- package/src/useAudioRecording.ts +141 -0
- package/tsconfig.json +9 -0
- package/yarn-error.log +72 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
let audioDataEvent: String = "AudioData"
|
|
4
|
+
|
|
5
|
+
public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
6
|
+
private var streamManager = AudioStreamManager()
|
|
7
|
+
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("ExpoAudioStream")
|
|
10
|
+
|
|
11
|
+
// Defines event names that the module can send to JavaScript.
|
|
12
|
+
Events(audioDataEvent)
|
|
13
|
+
|
|
14
|
+
OnCreate {
|
|
15
|
+
print("Setting streamManager delegate")
|
|
16
|
+
streamManager.delegate = self
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
AsyncFunction("startRecording") { (options: [String: Any], promise: Promise) in
|
|
20
|
+
self.checkMicrophonePermission { granted in
|
|
21
|
+
guard granted else {
|
|
22
|
+
promise.reject("PERMISSION_DENIED", "Recording permission has not been granted")
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract settings from provided options, using default values if necessary
|
|
27
|
+
let sampleRate = options["sampleRate"] as? Double ?? 48000.0
|
|
28
|
+
let numberOfChannels = options["channelConfig"] as? Int ?? 1
|
|
29
|
+
let bitDepth = options["audioFormat"] as? Int ?? 16
|
|
30
|
+
let interval = options["interval"] as? Int ?? 1000
|
|
31
|
+
|
|
32
|
+
let settings = RecordingSettings(sampleRate: sampleRate, numberOfChannels: numberOfChannels, bitDepth: bitDepth)
|
|
33
|
+
self.streamManager.startRecording(settings: settings, intervalMilliseconds: interval)
|
|
34
|
+
|
|
35
|
+
promise.resolve(nil)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Function("status") {
|
|
40
|
+
return self.streamManager.getStatus()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
AsyncFunction("stopRecording") { (promise: Promise) in
|
|
44
|
+
self.streamManager.stopRecording()
|
|
45
|
+
promise.resolve(nil)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
AsyncFunction("listAudioFiles") { (promise: Promise) in
|
|
49
|
+
let files = listAudioFiles()
|
|
50
|
+
promise.resolve(files)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Function("clearAudioFiles") {
|
|
54
|
+
clearAudioFiles()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func audioStreamManager(_ manager: AudioStreamManager, didReceiveAudioData data: Data, recordingTime: TimeInterval, totalDataSize: Int64) {
|
|
59
|
+
print("audioStreamManager debug sending data")
|
|
60
|
+
guard let fileURL = manager.recordingFileURL else { return }
|
|
61
|
+
let encodedData = data.base64EncodedString()
|
|
62
|
+
|
|
63
|
+
// Assuming `lastEmittedSize` and `streamUuid` are tracked within `AudioStreamManager`
|
|
64
|
+
let deltaSize = data.count // This needs to be calculated based on what was last sent if using chunks
|
|
65
|
+
let fileSize = totalDataSize // Total data size in bytes
|
|
66
|
+
|
|
67
|
+
// Construct the event payload similar to Android
|
|
68
|
+
let eventBody: [String: Any] = [
|
|
69
|
+
"fileUri": fileURL.absoluteString,
|
|
70
|
+
"from": manager.lastEmittedSize, // Needs to be maintained within AudioStreamManager
|
|
71
|
+
"encoded": encodedData,
|
|
72
|
+
"deltaSize": deltaSize,
|
|
73
|
+
"totalSize": fileSize,
|
|
74
|
+
"streamUuid": manager.recordingUUID?.uuidString ?? UUID().uuidString
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
// Update the last emitted size for the next calculation
|
|
78
|
+
manager.lastEmittedSize += Int64(deltaSize)
|
|
79
|
+
|
|
80
|
+
print("Sending audio data", eventBody)
|
|
81
|
+
// Emit the event to JavaScript
|
|
82
|
+
sendEvent(audioDataEvent, eventBody)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private func checkMicrophonePermission(completion: @escaping (Bool) -> Void) {
|
|
86
|
+
switch AVAudioSession.sharedInstance().recordPermission {
|
|
87
|
+
case .granted:
|
|
88
|
+
completion(true)
|
|
89
|
+
case .denied:
|
|
90
|
+
completion(false)
|
|
91
|
+
case .undetermined:
|
|
92
|
+
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
93
|
+
DispatchQueue.main.async {
|
|
94
|
+
completion(granted)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
@unknown default:
|
|
98
|
+
completion(false)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func clearAudioFiles() {
|
|
103
|
+
let filenames = listAudioFiles()
|
|
104
|
+
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
105
|
+
|
|
106
|
+
filenames.forEach { filename in
|
|
107
|
+
let fileURL = documentDirectory.appendingPathComponent(filename)
|
|
108
|
+
do {
|
|
109
|
+
try FileManager.default.removeItem(at: fileURL)
|
|
110
|
+
print("Removed file at:", fileURL.path)
|
|
111
|
+
} catch {
|
|
112
|
+
print("Error removing file at \(fileURL.path):", error.localizedDescription)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func listAudioFiles() -> [String] {
|
|
118
|
+
guard let documentDirectory = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else {
|
|
119
|
+
print("Failed to access document directory.")
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
let files = try FileManager.default.contentsOfDirectory(at: documentDirectory, includingPropertiesForKeys: nil)
|
|
125
|
+
let audioFiles = files.filter { $0.pathExtension == "pcm" }.map { $0.lastPathComponent }
|
|
126
|
+
return audioFiles
|
|
127
|
+
} catch {
|
|
128
|
+
print("Error listing audio files:", error.localizedDescription)
|
|
129
|
+
return []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siteed/expo-audio-stream",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "stream audio crossplatform",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"types": "build/index.d.ts",
|
|
8
|
+
"author": "Arthur Breton <abreton@siteed.net> (https://github.com/deeeed)",
|
|
9
|
+
"homepage": "https://github.com/deeeed/expo-audio-stream#readme",
|
|
10
|
+
"repository": "https://github.com/deeeed/expo-audio-stream",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/deeeed/expo-audio-stream/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"react-native",
|
|
16
|
+
"expo",
|
|
17
|
+
"expo-audio-stream",
|
|
18
|
+
"ExpoAudioStream"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "expo-module build",
|
|
22
|
+
"clean": "expo-module clean",
|
|
23
|
+
"lint": "expo-module lint",
|
|
24
|
+
"test": "expo-module test",
|
|
25
|
+
"prepare": "expo-module prepare",
|
|
26
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
27
|
+
"release": "release-it",
|
|
28
|
+
"expo-module": "expo-module",
|
|
29
|
+
"open:ios": "open -a \"Xcode\" example/ios",
|
|
30
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"base-64": "^1.0.0",
|
|
34
|
+
"expo-file-system": "^16.0.9"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@expo/config-plugins": "^7.9.1",
|
|
38
|
+
"@release-it/conventional-changelog": "^8.0.1",
|
|
39
|
+
"@types/react": "^18.0.25",
|
|
40
|
+
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
|
41
|
+
"@typescript-eslint/parser": "^7.7.0",
|
|
42
|
+
"eslint": "^8.56.0",
|
|
43
|
+
"eslint-config-prettier": "^9.1.0",
|
|
44
|
+
"eslint-config-universe": "^12.0.0",
|
|
45
|
+
"eslint-plugin-prettier": "^5.1.3",
|
|
46
|
+
"eslint-plugin-promise": "^6.1.1",
|
|
47
|
+
"eslint-plugin-react": "^7.34.1",
|
|
48
|
+
"expo-module-scripts": "^3.4.2",
|
|
49
|
+
"expo-modules-core": "^1.11.12",
|
|
50
|
+
"prettier": "^3.2.5",
|
|
51
|
+
"release-it": "^17.2.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"expo": "*",
|
|
55
|
+
"react": "*",
|
|
56
|
+
"react-native": "*"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
4
|
+
const pkg = require('../../package.json');
|
|
5
|
+
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
|
|
6
|
+
const withRecordingPermission = (config, { microphonePermission }) => {
|
|
7
|
+
config_plugins_1.IOSConfig.Permissions.createPermissionsPlugin({
|
|
8
|
+
NSMicrophoneUsageDescription: MICROPHONE_USAGE,
|
|
9
|
+
})(config, {
|
|
10
|
+
NSMicrophoneUsageDescription: microphonePermission,
|
|
11
|
+
});
|
|
12
|
+
return config_plugins_1.AndroidConfig.Permissions.withPermissions(config, [
|
|
13
|
+
microphonePermission !== false && 'android.permission.RECORD_AUDIO',
|
|
14
|
+
'android.permission.MODIFY_AUDIO_SETTINGS',
|
|
15
|
+
].filter(Boolean));
|
|
16
|
+
};
|
|
17
|
+
exports.default = (0, config_plugins_1.createRunOncePlugin)(withRecordingPermission, pkg.name, pkg.version);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AndroidConfig,
|
|
3
|
+
ConfigPlugin,
|
|
4
|
+
IOSConfig,
|
|
5
|
+
createRunOncePlugin
|
|
6
|
+
} from "@expo/config-plugins";
|
|
7
|
+
|
|
8
|
+
const pkg = require('../../package.json');
|
|
9
|
+
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
|
|
10
|
+
|
|
11
|
+
const withRecordingPermission: ConfigPlugin<{ microphonePermission: string | false }> = (config, { microphonePermission }) => {
|
|
12
|
+
IOSConfig.Permissions.createPermissionsPlugin({
|
|
13
|
+
NSMicrophoneUsageDescription: MICROPHONE_USAGE,
|
|
14
|
+
})(config, {
|
|
15
|
+
NSMicrophoneUsageDescription: microphonePermission,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return AndroidConfig.Permissions.withPermissions(
|
|
19
|
+
config,
|
|
20
|
+
[
|
|
21
|
+
microphonePermission !== false && 'android.permission.RECORD_AUDIO',
|
|
22
|
+
'android.permission.MODIFY_AUDIO_SETTINGS',
|
|
23
|
+
].filter(Boolean) as string[]
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default createRunOncePlugin(withRecordingPermission, pkg.name, pkg.version);
|
package/release-it.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
git: {
|
|
3
|
+
"commitMessage": "chore: release ${version}",
|
|
4
|
+
"tagName": "v${version}",
|
|
5
|
+
"requireCleanWorkingDir": false
|
|
6
|
+
},
|
|
7
|
+
npm: {
|
|
8
|
+
"publish": true
|
|
9
|
+
},
|
|
10
|
+
github: {
|
|
11
|
+
"release": true
|
|
12
|
+
},
|
|
13
|
+
plugins: {
|
|
14
|
+
"@release-it/conventional-changelog": {
|
|
15
|
+
"preset": "angular",
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface AudioEventPayload {
|
|
2
|
+
encoded?: string,
|
|
3
|
+
buffer?: Blob,
|
|
4
|
+
fileUri: string,
|
|
5
|
+
from: number,
|
|
6
|
+
deltaSize: number,
|
|
7
|
+
totalSize: number,
|
|
8
|
+
streamUuid: string,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface AudioStreamStatus {
|
|
12
|
+
isRecording: boolean;
|
|
13
|
+
isPaused: boolean;
|
|
14
|
+
duration: number;
|
|
15
|
+
size: number;
|
|
16
|
+
interval: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RecordingOptions {
|
|
20
|
+
// TODO align Android and IOS options
|
|
21
|
+
sampleRate?: number;
|
|
22
|
+
channelConfig?: number; // numberOfChannel
|
|
23
|
+
audioFormat?: number; // bitDepth (ENCODING_PCM_16BIT --> 2)
|
|
24
|
+
interval?: number;
|
|
25
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { EventEmitter } from "expo-modules-core";
|
|
2
|
+
import { AudioEventPayload, RecordingOptions } from "./ExpoAudioStream.types";
|
|
3
|
+
|
|
4
|
+
class ExpoAudioStreamWeb extends EventEmitter {
|
|
5
|
+
mediaRecorder: MediaRecorder | null;
|
|
6
|
+
audioChunks: Blob[];
|
|
7
|
+
isRecording: boolean;
|
|
8
|
+
isPaused: boolean;
|
|
9
|
+
recordingStartTime: number;
|
|
10
|
+
pausedTime: number;
|
|
11
|
+
currentDuration: number;
|
|
12
|
+
currentSize: number;
|
|
13
|
+
currentInterval: number;
|
|
14
|
+
lastEmittedSize: number;
|
|
15
|
+
streamUuid: string | null;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
const mockNativeModule = {
|
|
19
|
+
addListener: (eventName: string) => {
|
|
20
|
+
console.log(`Web addListener called for ${eventName}`);
|
|
21
|
+
},
|
|
22
|
+
removeListeners: (count: number) => {
|
|
23
|
+
console.log(`Web removeListeners called, count: ${count}`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
super(mockNativeModule); // Pass the mock native module to the parent class
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
this.mediaRecorder = null;
|
|
30
|
+
this.audioChunks = [];
|
|
31
|
+
this.isRecording = false;
|
|
32
|
+
this.isPaused = false;
|
|
33
|
+
this.recordingStartTime = 0;
|
|
34
|
+
this.pausedTime = 0;
|
|
35
|
+
this.currentDuration = 0;
|
|
36
|
+
this.currentSize = 0;
|
|
37
|
+
this.currentInterval = 1000; // Default interval in ms
|
|
38
|
+
this.lastEmittedSize = 0;
|
|
39
|
+
this.streamUuid = null; // Initialize UUID on first recording start
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Utility to handle user media stream
|
|
43
|
+
async getMediaStream() {
|
|
44
|
+
try {
|
|
45
|
+
return await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error("Failed to get media stream:", error);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Start recording with options
|
|
53
|
+
async startRecording(options: RecordingOptions = {}) {
|
|
54
|
+
if (this.isRecording) {
|
|
55
|
+
throw new Error('Recording is already in progress');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stream = await this.getMediaStream();
|
|
59
|
+
this.mediaRecorder = new MediaRecorder(stream);
|
|
60
|
+
this.setupRecordingListeners();
|
|
61
|
+
this.mediaRecorder.start(options.interval || this.currentInterval);
|
|
62
|
+
this.isRecording = true;
|
|
63
|
+
this.recordingStartTime = Date.now();
|
|
64
|
+
this.pausedTime = 0;
|
|
65
|
+
this.lastEmittedSize = 0;
|
|
66
|
+
this.streamUuid = this.generateUUID(); // Generate a UUID for the new recording session
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Setup listeners for the MediaRecorder
|
|
70
|
+
setupRecordingListeners() {
|
|
71
|
+
if (!this.mediaRecorder) {
|
|
72
|
+
throw new Error('No active media recorder');
|
|
73
|
+
}
|
|
74
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
75
|
+
this.audioChunks.push(event.data);
|
|
76
|
+
this.currentSize += event.data.size; // Update the size of the recording
|
|
77
|
+
this.emitAudioEvent(event.data); // Emit the event with the correct payload
|
|
78
|
+
this.lastEmittedSize = this.currentSize;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.mediaRecorder.onstop = () => {
|
|
82
|
+
this.isRecording = false;
|
|
83
|
+
console.log('Recording stopped', this.audioChunks);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
this.mediaRecorder.onpause = () => {
|
|
87
|
+
this.isPaused = true;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.mediaRecorder.onresume = () => {
|
|
91
|
+
this.isPaused = false;
|
|
92
|
+
this.recordingStartTime += (Date.now() - this.pausedTime); // Adjust start time after resuming
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
emitAudioEvent(data: Blob) {
|
|
97
|
+
const fileUri = `${this.streamUuid}.pcm`;
|
|
98
|
+
const audioEventPayload: AudioEventPayload = {
|
|
99
|
+
fileUri: fileUri,
|
|
100
|
+
from: this.lastEmittedSize, // Since this might be continuously streaming, adjust accordingly
|
|
101
|
+
deltaSize: data.size,
|
|
102
|
+
totalSize: this.currentSize,
|
|
103
|
+
buffer: data,
|
|
104
|
+
streamUuid: this.streamUuid ?? '', // Generate or manage UUID for stream identification
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
this.emit('AudioData', audioEventPayload);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Helper method to generate a UUID
|
|
111
|
+
generateUUID() {
|
|
112
|
+
// Implementation of UUID generation (use a library or custom method)
|
|
113
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, (c) => {
|
|
114
|
+
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
115
|
+
return v.toString(16);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Stop recording
|
|
120
|
+
async stopRecording() {
|
|
121
|
+
console.debug('Stopping recording', this);
|
|
122
|
+
this.mediaRecorder?.stop();
|
|
123
|
+
this.isRecording = false;
|
|
124
|
+
this.currentDuration = (Date.now() - this.recordingStartTime) / 1000;
|
|
125
|
+
return this.currentDuration;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Pause recording
|
|
129
|
+
async pauseRecording() {
|
|
130
|
+
if (!this.mediaRecorder) {
|
|
131
|
+
throw new Error('No active media recorder');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.isRecording && !this.isPaused) {
|
|
135
|
+
this.mediaRecorder.pause();
|
|
136
|
+
this.pausedTime = Date.now();
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error('Recording is not active or already paused');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Get current status
|
|
143
|
+
status() {
|
|
144
|
+
return {
|
|
145
|
+
isRecording: this.isRecording,
|
|
146
|
+
isPaused: this.isPaused,
|
|
147
|
+
duration: Date.now() - this.recordingStartTime,
|
|
148
|
+
size: this.currentSize,
|
|
149
|
+
interval: this.currentInterval,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
listAudioFiles() {
|
|
154
|
+
// Not applicable on web
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
clearAudioFiles() {
|
|
158
|
+
// Not applicable on web
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default new ExpoAudioStreamWeb();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NativeModulesProxy, EventEmitter, type Subscription } from 'expo-modules-core';
|
|
2
|
+
|
|
3
|
+
// Import the native module. On web, it will be resolved to ExpoAudioStream.web.ts
|
|
4
|
+
// and on native platforms to ExpoAudioStream.ts
|
|
5
|
+
import ExpoAudioStreamModule from './ExpoAudioStreamModule';
|
|
6
|
+
import { AudioEventPayload, RecordingOptions } from './ExpoAudioStream.types';
|
|
7
|
+
import { useAudioRecorder } from './useAudioRecording';
|
|
8
|
+
|
|
9
|
+
const emitter = new EventEmitter(ExpoAudioStreamModule ?? NativeModulesProxy.ExpoAudioStream);
|
|
10
|
+
|
|
11
|
+
// Function to get the recording duration
|
|
12
|
+
export function getRecordingDuration(): Promise<number> {
|
|
13
|
+
return ExpoAudioStreamModule.getRecordingDuration();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function listAudioFiles(): Promise<string[]> {
|
|
17
|
+
return ExpoAudioStreamModule.listAudioFiles();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearAudioFiles(): Promise<void> {
|
|
21
|
+
return ExpoAudioStreamModule.clearAudioFiles();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function addChangeListener(listener: (event: AudioEventPayload) => void): Subscription {
|
|
25
|
+
return emitter.addListener<AudioEventPayload>('AudioData', listener);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export { AudioEventPayload, useAudioRecorder };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { NativeModulesProxy, EventEmitter, type Subscription, Platform } from 'expo-modules-core';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import ExpoAudioStreamModule from './ExpoAudioStreamModule';
|
|
5
|
+
import { AudioEventPayload, AudioStreamStatus, RecordingOptions } from "./ExpoAudioStream.types";
|
|
6
|
+
import { addChangeListener } from '.';
|
|
7
|
+
import * as FileSystem from 'expo-file-system';
|
|
8
|
+
import { decode as atob } from 'base-64';
|
|
9
|
+
|
|
10
|
+
const emitter = new EventEmitter(ExpoAudioStreamModule ?? NativeModulesProxy.ExpoAudioStream);
|
|
11
|
+
|
|
12
|
+
interface UseAudioRecorderState {
|
|
13
|
+
startRecording: (_: RecordingOptions) => Promise<void>;
|
|
14
|
+
stopRecording: () => Promise<number>;
|
|
15
|
+
pauseRecording: () => void;
|
|
16
|
+
isRecording: boolean;
|
|
17
|
+
isPaused: boolean;
|
|
18
|
+
duration: number; // Duration of the recording
|
|
19
|
+
size: number; // Size in bytes of the recorded audio
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAudioRecorder({onAudioStream}: {onAudioStream?: (buffer: Blob) => void}): UseAudioRecorderState {
|
|
23
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
24
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
25
|
+
const [duration, setDuration] = useState(0);
|
|
26
|
+
const [size, setSize] = useState(0);
|
|
27
|
+
|
|
28
|
+
useEffect( () => {
|
|
29
|
+
if(isRecording || isPaused) {
|
|
30
|
+
const interval = setInterval(() => {
|
|
31
|
+
const status: AudioStreamStatus = ExpoAudioStreamModule.status()
|
|
32
|
+
setDuration(status.duration);
|
|
33
|
+
setSize(status.size);
|
|
34
|
+
}, 1000);
|
|
35
|
+
return () => clearInterval(interval);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return () => null;
|
|
39
|
+
}, [isRecording, isPaused])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const subscribe = addChangeListener(async ({fileUri, deltaSize, totalSize, from, streamUuid, encoded, buffer}) => {
|
|
44
|
+
console.debug(`Received audio event:`, {fileUri, deltaSize, totalSize, from, streamUuid, encodedLength: encoded?.length})
|
|
45
|
+
if(deltaSize > 0) {
|
|
46
|
+
// Fetch the audio data from the fileUri
|
|
47
|
+
const options = {
|
|
48
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
49
|
+
position: from,
|
|
50
|
+
length: deltaSize,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if(Platform.OS !== 'web') {
|
|
54
|
+
// Read the audio file as a base64 string for comparison
|
|
55
|
+
try {
|
|
56
|
+
const base64Content = await FileSystem.readAsStringAsync(fileUri, options);
|
|
57
|
+
const binaryData = atob(base64Content);
|
|
58
|
+
const content = new Uint8Array(binaryData.length);
|
|
59
|
+
for (let i = 0; i < binaryData.length; i++) {
|
|
60
|
+
content[i] = binaryData.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// TODO: get the filetype based on audio setting and encoding
|
|
64
|
+
const audioBlob = new Blob([content], { type: 'application/octet-stream' }); // Create a Blob from the byte array
|
|
65
|
+
console.debug(`Read audio file (len: ${content.length}) vs ${deltaSize}`)
|
|
66
|
+
onAudioStream?.(audioBlob);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error('Error reading audio file:', error);
|
|
69
|
+
}
|
|
70
|
+
} else if(buffer) {
|
|
71
|
+
onAudioStream?.(buffer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return () => subscribe.remove();
|
|
76
|
+
}, [isRecording, onAudioStream]);
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
const startRecording = useCallback(async (recordingOptions: RecordingOptions) => {
|
|
80
|
+
if (!isRecording) {
|
|
81
|
+
setIsRecording(true);
|
|
82
|
+
setIsPaused(false);
|
|
83
|
+
setSize(0);
|
|
84
|
+
setDuration(0);
|
|
85
|
+
const startTime = Date.now();
|
|
86
|
+
|
|
87
|
+
console.log(`module shims`, ExpoAudioStreamModule)
|
|
88
|
+
try {
|
|
89
|
+
console.log(`start recoding`, recordingOptions)
|
|
90
|
+
await ExpoAudioStreamModule.startRecording(recordingOptions);
|
|
91
|
+
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Error starting recording:', error);
|
|
94
|
+
setIsRecording(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, [isRecording]);
|
|
98
|
+
|
|
99
|
+
const stopRecording = useCallback(async (): Promise<number> => {
|
|
100
|
+
if (isRecording) {
|
|
101
|
+
setIsRecording(false);
|
|
102
|
+
setIsPaused(false);
|
|
103
|
+
try {
|
|
104
|
+
const recordedDuration = await ExpoAudioStreamModule.stopRecording();
|
|
105
|
+
setDuration(recordedDuration);
|
|
106
|
+
return recordedDuration;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Error stopping recording:', error);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}, [isRecording]);
|
|
114
|
+
|
|
115
|
+
const pauseRecording = useCallback(() => {
|
|
116
|
+
if (isRecording) {
|
|
117
|
+
ExpoAudioStreamModule.stopRecording().catch(console.error);
|
|
118
|
+
setIsPaused(true);
|
|
119
|
+
setIsRecording(false);
|
|
120
|
+
}
|
|
121
|
+
}, [isRecording]);
|
|
122
|
+
|
|
123
|
+
// Cleanup listener on unmount to prevent memory leaks
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
return () => {
|
|
126
|
+
if (isRecording) {
|
|
127
|
+
ExpoAudioStreamModule.stopRecording().catch(console.error);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}, [isRecording]);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
startRecording,
|
|
134
|
+
stopRecording,
|
|
135
|
+
pauseRecording,
|
|
136
|
+
isPaused,
|
|
137
|
+
isRecording,
|
|
138
|
+
duration,
|
|
139
|
+
size
|
|
140
|
+
};
|
|
141
|
+
}
|