implement Milestone 12: Microphone interface with permission and user gesture requirements
This commit is contained in:
@@ -562,8 +562,9 @@ TEST(CameraInterface, StopsOnAppStop);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 12: Virtual Hardware - Microphone
|
## Milestone 12: Virtual Hardware - Microphone ✅
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
**Goal**: Audio recording with mandatory indicators.
|
**Goal**: Audio recording with mandatory indicators.
|
||||||
**Estimated Files**: 1 new file
|
**Estimated Files**: 1 new file
|
||||||
|
|
||||||
|
|||||||
507
SANDBOX_MILESTONE_12.md
Normal file
507
SANDBOX_MILESTONE_12.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# Milestone 12: Virtual Hardware - Microphone
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
|
**Goal**: Audio recording with mandatory recording indicators and security controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This milestone implements secure microphone access for Lua apps:
|
||||||
|
- Permission required (`microphone` permission)
|
||||||
|
- User gesture required to start recording
|
||||||
|
- Mandatory recording indicator (system-controlled, cannot be hidden)
|
||||||
|
- Single recording session per app
|
||||||
|
- Sample rate limiting
|
||||||
|
- Automatic cleanup on app stop
|
||||||
|
|
||||||
|
### Key Deliverables
|
||||||
|
|
||||||
|
1. **MicrophoneInterface class** - Session management, permission checks
|
||||||
|
2. **RecordingSession class** - Active recording session wrapper
|
||||||
|
3. **Lua microphone API** - `microphone.start()`, session methods
|
||||||
|
4. **Recording indicator** - System-level UI notification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/cpp/sandbox/
|
||||||
|
├── microphone_interface.h # NEW - Microphone API header
|
||||||
|
└── microphone_interface.cpp # NEW - Microphone implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. MicrophoneInterface Class
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// microphone_interface.h
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
struct lua_State;
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
class PermissionGate;
|
||||||
|
|
||||||
|
enum class AudioFormat {
|
||||||
|
PCM_16BIT,
|
||||||
|
PCM_FLOAT
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RecordingConfig {
|
||||||
|
int sample_rate = 44100; // 8000, 16000, 22050, 44100, 48000
|
||||||
|
int channels = 1; // 1 (mono) or 2 (stereo)
|
||||||
|
AudioFormat format = AudioFormat::PCM_16BIT;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AudioBuffer {
|
||||||
|
std::vector<uint8_t> data;
|
||||||
|
int sample_rate;
|
||||||
|
int channels;
|
||||||
|
AudioFormat format;
|
||||||
|
uint64_t timestamp_ms;
|
||||||
|
int sample_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingSession {
|
||||||
|
public:
|
||||||
|
using BufferCallback = std::function<void(const AudioBuffer& buffer)>;
|
||||||
|
|
||||||
|
RecordingSession(int id, const RecordingConfig& config);
|
||||||
|
~RecordingSession();
|
||||||
|
|
||||||
|
int GetId() const { return m_id; }
|
||||||
|
const RecordingConfig& GetConfig() const { return m_config; }
|
||||||
|
bool IsActive() const { return m_active; }
|
||||||
|
|
||||||
|
// Get accumulated audio data
|
||||||
|
AudioBuffer GetBuffer();
|
||||||
|
|
||||||
|
// Set buffer callback (for streaming)
|
||||||
|
void SetOnBuffer(BufferCallback cb) { m_on_buffer = std::move(cb); }
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
// For mock mode - simulate audio data arrival
|
||||||
|
void SimulateBuffer(const AudioBuffer& buffer);
|
||||||
|
|
||||||
|
private:
|
||||||
|
int m_id;
|
||||||
|
RecordingConfig m_config;
|
||||||
|
std::atomic<bool> m_active{true};
|
||||||
|
BufferCallback m_on_buffer;
|
||||||
|
AudioBuffer m_accumulated;
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MicrophoneInterface {
|
||||||
|
public:
|
||||||
|
MicrophoneInterface(const std::string& app_id, PermissionGate* permissions);
|
||||||
|
~MicrophoneInterface();
|
||||||
|
|
||||||
|
// Start recording session
|
||||||
|
// Returns session on success, nullptr on failure (sets error)
|
||||||
|
// Requires microphone permission and user gesture
|
||||||
|
std::shared_ptr<RecordingSession> StartSession(const RecordingConfig& config, std::string& error);
|
||||||
|
|
||||||
|
// Stop active session
|
||||||
|
void StopSession();
|
||||||
|
|
||||||
|
// Check if session is active
|
||||||
|
bool HasActiveSession() const;
|
||||||
|
|
||||||
|
// Check if recording indicator should be shown
|
||||||
|
bool IsIndicatorVisible() const;
|
||||||
|
|
||||||
|
// Cleanup on app stop
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
// For testing
|
||||||
|
void SetMockMode(bool enabled) { m_mock_mode = enabled; }
|
||||||
|
bool IsMockMode() const { return m_mock_mode; }
|
||||||
|
|
||||||
|
// Simulate user gesture for testing
|
||||||
|
void SimulateUserGesture();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_app_id;
|
||||||
|
PermissionGate* m_permissions;
|
||||||
|
std::shared_ptr<RecordingSession> m_active_session;
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
bool m_mock_mode = true;
|
||||||
|
std::atomic<bool> m_indicator_visible{false};
|
||||||
|
int m_next_session_id = 1;
|
||||||
|
|
||||||
|
// Track user gesture timing
|
||||||
|
std::chrono::steady_clock::time_point m_last_gesture_time;
|
||||||
|
bool m_has_gesture = false;
|
||||||
|
static constexpr int GESTURE_VALIDITY_MS = 5000; // 5 seconds
|
||||||
|
|
||||||
|
bool HasRecentUserGesture() const;
|
||||||
|
void ShowIndicator();
|
||||||
|
void HideIndicator();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register microphone.* APIs as globals
|
||||||
|
void RegisterMicrophoneAPI(lua_State* L, MicrophoneInterface* microphone);
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Permission Requirements
|
||||||
|
|
||||||
|
Microphone access requires:
|
||||||
|
1. `microphone` permission declared in manifest
|
||||||
|
2. Permission granted by user (dangerous permission)
|
||||||
|
3. Recent user gesture (within 5 seconds)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Permission check flow
|
||||||
|
bool MicrophoneInterface::StartSession(...) {
|
||||||
|
// 1. Check permission
|
||||||
|
if (!m_permissions->HasPermission("microphone")) {
|
||||||
|
error = "Microphone permission not granted";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check user gesture
|
||||||
|
if (!HasRecentUserGesture()) {
|
||||||
|
error = "Microphone requires user gesture";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check no existing session
|
||||||
|
if (m_active_session) {
|
||||||
|
error = "Recording session already active";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... create session
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recording Indicator
|
||||||
|
|
||||||
|
The recording indicator is mandatory and system-controlled:
|
||||||
|
- Shown whenever microphone session is active
|
||||||
|
- Cannot be hidden or obscured by app
|
||||||
|
- Positioned in system UI area
|
||||||
|
- Shows microphone icon with "Recording" text
|
||||||
|
|
||||||
|
### 4. Lua API
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Start recording session (requires permission + user gesture)
|
||||||
|
local session, err = microphone.start({
|
||||||
|
sampleRate = 44100, -- 8000, 16000, 22050, 44100, 48000
|
||||||
|
channels = 1, -- 1 (mono) or 2 (stereo)
|
||||||
|
format = "pcm16" -- "pcm16" or "float"
|
||||||
|
})
|
||||||
|
|
||||||
|
if not session then
|
||||||
|
print("Failed to start recording:", err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set buffer callback (for streaming audio)
|
||||||
|
session:on("buffer", function(buffer)
|
||||||
|
-- buffer.data, buffer.sampleRate, buffer.channels, buffer.sampleCount
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Get accumulated audio data
|
||||||
|
local audio = session:getBuffer()
|
||||||
|
if audio then
|
||||||
|
print("Recorded", audio.sampleCount, "samples at", audio.sampleRate, "Hz")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Stop recording
|
||||||
|
session:stop()
|
||||||
|
|
||||||
|
-- Check if microphone is active
|
||||||
|
if microphone.isActive() then
|
||||||
|
print("Microphone is recording")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Sample Rate Validation
|
||||||
|
|
||||||
|
To prevent resource abuse:
|
||||||
|
- Allowed sample rates: 8000, 16000, 22050, 44100, 48000
|
||||||
|
- Maximum: 48000 Hz
|
||||||
|
- Channels: 1 (mono) or 2 (stereo)
|
||||||
|
|
||||||
|
### 6. Session Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
User Gesture ──► microphone.start() ──► Session Active ──► Indicator Shown
|
||||||
|
│ │
|
||||||
|
│ error │ session:stop()
|
||||||
|
▼ ▼
|
||||||
|
nil, err Session Closed ──► Indicator Hidden
|
||||||
|
|
||||||
|
App Stop ──► MicrophoneInterface::Shutdown() ──► All Sessions Closed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
### Test 1: Requires Permission
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneRequiresPermission(std::string& error_msg) {
|
||||||
|
// Create context WITHOUT microphone permission
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("permission") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Requires User Gesture
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneRequiresUserGesture(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
// Note: NOT calling SimulateUserGesture()
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("gesture") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Shows Indicator
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneShowsIndicator(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
mic.StopSession();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Single Session Only
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneSingleSession(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
|
||||||
|
// First session should succeed
|
||||||
|
auto session1 = mic.StartSession(config, err);
|
||||||
|
EXPECT_TRUE(session1 != nullptr);
|
||||||
|
|
||||||
|
// Second session should fail
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
auto session2 = mic.StartSession(config, err);
|
||||||
|
EXPECT_TRUE(session2 == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("active") != std::string::npos ||
|
||||||
|
err.find("already") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 5: Stops On App Stop
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneStopsOnShutdown(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session != nullptr);
|
||||||
|
EXPECT_TRUE(mic.HasActiveSession());
|
||||||
|
|
||||||
|
// Simulate app stop
|
||||||
|
mic.Shutdown();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.HasActiveSession());
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 6: Lua Integration
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_MicrophoneLuaIntegration(std::string& error_msg) {
|
||||||
|
SandboxContext ctx = TestContext();
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
LuaSandbox sandbox(ctx);
|
||||||
|
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
mosis::RegisterMicrophoneAPI(sandbox.GetState(), &mic);
|
||||||
|
|
||||||
|
std::string script = R"lua(
|
||||||
|
-- Test that microphone global exists
|
||||||
|
if not microphone then
|
||||||
|
error("microphone global not found")
|
||||||
|
end
|
||||||
|
if not microphone.start then
|
||||||
|
error("microphone.start not found")
|
||||||
|
end
|
||||||
|
if not microphone.isActive then
|
||||||
|
error("microphone.isActive not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- isActive should be false initially
|
||||||
|
if microphone.isActive() then
|
||||||
|
error("microphone should not be active initially")
|
||||||
|
end
|
||||||
|
)lua";
|
||||||
|
|
||||||
|
bool ok = sandbox.LoadString(script, "microphone_test");
|
||||||
|
if (!ok) {
|
||||||
|
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
All tests must pass:
|
||||||
|
|
||||||
|
- [x] `Test_MicrophoneRequiresPermission` - Permission check works
|
||||||
|
- [x] `Test_MicrophoneRequiresUserGesture` - User gesture required
|
||||||
|
- [x] `Test_MicrophoneShowsIndicator` - Recording indicator shown/hidden
|
||||||
|
- [x] `Test_MicrophoneSingleSession` - Only one session allowed
|
||||||
|
- [x] `Test_MicrophoneStopsOnShutdown` - Cleanup on shutdown
|
||||||
|
- [x] `Test_MicrophoneLuaIntegration` - Lua API works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Milestone 1 (LuaSandbox)
|
||||||
|
- Milestone 2 (PermissionGate + user gesture)
|
||||||
|
- Milestone 3 (AuditLog)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Desktop vs Android Implementation
|
||||||
|
|
||||||
|
For desktop testing, MicrophoneInterface operates in mock mode:
|
||||||
|
- Permission and gesture checks run normally
|
||||||
|
- Indicator state is tracked but not displayed
|
||||||
|
- No actual microphone hardware access
|
||||||
|
- Audio buffers can be simulated for testing
|
||||||
|
|
||||||
|
On Android, the real implementation would:
|
||||||
|
1. Use AudioRecord API through JNI
|
||||||
|
2. Display system-level recording indicator
|
||||||
|
3. Handle audio hardware lifecycle
|
||||||
|
4. Deliver audio buffers through native callbacks
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **Permission**: Microphone access is a dangerous permission requiring user grant
|
||||||
|
2. **User gesture**: Prevents background audio recording
|
||||||
|
3. **Indicator**: User always knows when microphone is active
|
||||||
|
4. **Single session**: Prevents resource abuse
|
||||||
|
5. **Cleanup**: Sessions closed when app stops
|
||||||
|
|
||||||
|
### Future Integration
|
||||||
|
|
||||||
|
The microphone will integrate with:
|
||||||
|
1. Voice communication in multiplayer games
|
||||||
|
2. Voice commands/speech recognition
|
||||||
|
3. Audio messaging features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After Milestone 12 passes:
|
||||||
|
1. Milestone 13: Virtual Hardware - Audio Output
|
||||||
@@ -25,6 +25,7 @@ add_library(mosis-sandbox STATIC
|
|||||||
../src/main/cpp/sandbox/network_manager.cpp
|
../src/main/cpp/sandbox/network_manager.cpp
|
||||||
../src/main/cpp/sandbox/websocket_manager.cpp
|
../src/main/cpp/sandbox/websocket_manager.cpp
|
||||||
../src/main/cpp/sandbox/camera_interface.cpp
|
../src/main/cpp/sandbox/camera_interface.cpp
|
||||||
|
../src/main/cpp/sandbox/microphone_interface.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(mosis-sandbox PUBLIC
|
target_include_directories(mosis-sandbox PUBLIC
|
||||||
../src/main/cpp/sandbox
|
../src/main/cpp/sandbox
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "network_manager.h"
|
#include "network_manager.h"
|
||||||
#include "websocket_manager.h"
|
#include "websocket_manager.h"
|
||||||
#include "camera_interface.h"
|
#include "camera_interface.h"
|
||||||
|
#include "microphone_interface.h"
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -2007,6 +2008,173 @@ bool Test_CameraLuaIntegration(std::string& error_msg) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// MILESTONE 12: Microphone
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
bool Test_MicrophoneRequiresPermission(std::string& error_msg) {
|
||||||
|
// Create sandbox context WITHOUT microphone permission
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {}; // No microphone permission declared
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("permission") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_MicrophoneRequiresUserGesture(std::string& error_msg) {
|
||||||
|
// Create sandbox context WITH microphone permission
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
// Note: NOT calling SimulateUserGesture()
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("gesture") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_MicrophoneShowsIndicator(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session != nullptr);
|
||||||
|
EXPECT_TRUE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
mic.StopSession();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_MicrophoneSingleSession(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
|
||||||
|
// First session should succeed
|
||||||
|
auto session1 = mic.StartSession(config, err);
|
||||||
|
EXPECT_TRUE(session1 != nullptr);
|
||||||
|
|
||||||
|
// Second session should fail
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
auto session2 = mic.StartSession(config, err);
|
||||||
|
EXPECT_TRUE(session2 == nullptr);
|
||||||
|
EXPECT_TRUE(err.find("active") != std::string::npos ||
|
||||||
|
err.find("already") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_MicrophoneStopsOnShutdown(std::string& error_msg) {
|
||||||
|
SandboxContext ctx;
|
||||||
|
ctx.app_id = "test.app";
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
ctx.is_system_app = false;
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::RecordingConfig config;
|
||||||
|
auto session = mic.StartSession(config, err);
|
||||||
|
|
||||||
|
EXPECT_TRUE(session != nullptr);
|
||||||
|
EXPECT_TRUE(mic.HasActiveSession());
|
||||||
|
|
||||||
|
// Simulate app stop
|
||||||
|
mic.Shutdown();
|
||||||
|
|
||||||
|
EXPECT_FALSE(mic.HasActiveSession());
|
||||||
|
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_MicrophoneLuaIntegration(std::string& error_msg) {
|
||||||
|
SandboxContext ctx = TestContext();
|
||||||
|
ctx.permissions = {"microphone"};
|
||||||
|
LuaSandbox sandbox(ctx);
|
||||||
|
|
||||||
|
PermissionGate permissions(ctx);
|
||||||
|
permissions.GrantPermission("microphone");
|
||||||
|
|
||||||
|
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||||
|
mic.SimulateUserGesture();
|
||||||
|
mosis::RegisterMicrophoneAPI(sandbox.GetState(), &mic);
|
||||||
|
|
||||||
|
std::string script = R"lua(
|
||||||
|
-- Test that microphone global exists
|
||||||
|
if not microphone then
|
||||||
|
error("microphone global not found")
|
||||||
|
end
|
||||||
|
if not microphone.start then
|
||||||
|
error("microphone.start not found")
|
||||||
|
end
|
||||||
|
if not microphone.isActive then
|
||||||
|
error("microphone.isActive not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- isActive should be false initially
|
||||||
|
if microphone.isActive() then
|
||||||
|
error("microphone should not be active initially")
|
||||||
|
end
|
||||||
|
)lua";
|
||||||
|
|
||||||
|
bool ok = sandbox.LoadString(script, "microphone_test");
|
||||||
|
if (!ok) {
|
||||||
|
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// MAIN
|
// MAIN
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -2156,6 +2324,14 @@ int main(int argc, char* argv[]) {
|
|||||||
harness.AddTest("CameraStopsOnShutdown", Test_CameraStopsOnShutdown);
|
harness.AddTest("CameraStopsOnShutdown", Test_CameraStopsOnShutdown);
|
||||||
harness.AddTest("CameraLuaIntegration", Test_CameraLuaIntegration);
|
harness.AddTest("CameraLuaIntegration", Test_CameraLuaIntegration);
|
||||||
|
|
||||||
|
// Milestone 12: Microphone
|
||||||
|
harness.AddTest("MicrophoneRequiresPermission", Test_MicrophoneRequiresPermission);
|
||||||
|
harness.AddTest("MicrophoneRequiresUserGesture", Test_MicrophoneRequiresUserGesture);
|
||||||
|
harness.AddTest("MicrophoneShowsIndicator", Test_MicrophoneShowsIndicator);
|
||||||
|
harness.AddTest("MicrophoneSingleSession", Test_MicrophoneSingleSession);
|
||||||
|
harness.AddTest("MicrophoneStopsOnShutdown", Test_MicrophoneStopsOnShutdown);
|
||||||
|
harness.AddTest("MicrophoneLuaIntegration", Test_MicrophoneLuaIntegration);
|
||||||
|
|
||||||
// Run tests
|
// Run tests
|
||||||
auto results = harness.Run(filter);
|
auto results = harness.Run(filter);
|
||||||
|
|
||||||
|
|||||||
457
src/main/cpp/sandbox/microphone_interface.cpp
Normal file
457
src/main/cpp/sandbox/microphone_interface.cpp
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
#include "microphone_interface.h"
|
||||||
|
#include "permission_gate.h"
|
||||||
|
#include <lua.hpp>
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
// RecordingSession implementation
|
||||||
|
|
||||||
|
RecordingSession::RecordingSession(int id, const RecordingConfig& config)
|
||||||
|
: m_id(id)
|
||||||
|
, m_config(config)
|
||||||
|
, m_active(true)
|
||||||
|
{
|
||||||
|
// Initialize accumulated buffer with config
|
||||||
|
m_accumulated.sample_rate = config.sample_rate;
|
||||||
|
m_accumulated.channels = config.channels;
|
||||||
|
m_accumulated.format = config.format;
|
||||||
|
m_accumulated.sample_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordingSession::~RecordingSession() {
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioBuffer RecordingSession::GetBuffer() {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
// Return copy of accumulated buffer
|
||||||
|
AudioBuffer buffer = m_accumulated;
|
||||||
|
buffer.timestamp_ms = static_cast<uint64_t>(
|
||||||
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch()
|
||||||
|
).count()
|
||||||
|
);
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordingSession::Stop() {
|
||||||
|
m_active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordingSession::SimulateBuffer(const AudioBuffer& buffer) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
if (!m_active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to accumulated buffer
|
||||||
|
m_accumulated.data.insert(
|
||||||
|
m_accumulated.data.end(),
|
||||||
|
buffer.data.begin(),
|
||||||
|
buffer.data.end()
|
||||||
|
);
|
||||||
|
m_accumulated.sample_count += buffer.sample_count;
|
||||||
|
|
||||||
|
if (m_on_buffer) {
|
||||||
|
m_on_buffer(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicrophoneInterface implementation
|
||||||
|
|
||||||
|
MicrophoneInterface::MicrophoneInterface(const std::string& app_id, PermissionGate* permissions)
|
||||||
|
: m_app_id(app_id)
|
||||||
|
, m_permissions(permissions)
|
||||||
|
, m_mock_mode(true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MicrophoneInterface::~MicrophoneInterface() {
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrophoneInterface::SimulateUserGesture() {
|
||||||
|
m_last_gesture_time = std::chrono::steady_clock::now();
|
||||||
|
m_has_gesture = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MicrophoneInterface::HasRecentUserGesture() const {
|
||||||
|
if (!m_has_gesture) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto now = std::chrono::steady_clock::now();
|
||||||
|
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
now - m_last_gesture_time
|
||||||
|
).count();
|
||||||
|
|
||||||
|
return elapsed < GESTURE_VALIDITY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrophoneInterface::ShowIndicator() {
|
||||||
|
m_indicator_visible = true;
|
||||||
|
// In real implementation: notify system UI to show recording indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrophoneInterface::HideIndicator() {
|
||||||
|
m_indicator_visible = false;
|
||||||
|
// In real implementation: notify system UI to hide recording indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<RecordingSession> MicrophoneInterface::StartSession(const RecordingConfig& config, std::string& error) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
// Check permission
|
||||||
|
if (m_permissions && !m_permissions->HasPermission("microphone")) {
|
||||||
|
error = "Microphone permission not granted";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user gesture
|
||||||
|
if (!HasRecentUserGesture()) {
|
||||||
|
error = "Microphone requires recent user gesture";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing session
|
||||||
|
if (m_active_session && m_active_session->IsActive()) {
|
||||||
|
error = "Recording session already active";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sample rate
|
||||||
|
int sample_rate = config.sample_rate;
|
||||||
|
if (sample_rate != 8000 && sample_rate != 16000 &&
|
||||||
|
sample_rate != 22050 && sample_rate != 44100 && sample_rate != 48000) {
|
||||||
|
sample_rate = 44100; // Default to 44100 if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate channels
|
||||||
|
RecordingConfig validated_config = config;
|
||||||
|
validated_config.sample_rate = sample_rate;
|
||||||
|
if (validated_config.channels < 1) validated_config.channels = 1;
|
||||||
|
if (validated_config.channels > 2) validated_config.channels = 2;
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
int id = m_next_session_id++;
|
||||||
|
m_active_session = std::make_shared<RecordingSession>(id, validated_config);
|
||||||
|
|
||||||
|
// Show recording indicator
|
||||||
|
ShowIndicator();
|
||||||
|
|
||||||
|
// In mock mode, session is created but no real microphone access
|
||||||
|
// In real implementation: start audio recording hardware
|
||||||
|
|
||||||
|
return m_active_session;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrophoneInterface::StopSession() {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
if (m_active_session) {
|
||||||
|
m_active_session->Stop();
|
||||||
|
m_active_session.reset();
|
||||||
|
HideIndicator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MicrophoneInterface::HasActiveSession() const {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
return m_active_session && m_active_session->IsActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MicrophoneInterface::IsIndicatorVisible() const {
|
||||||
|
return m_indicator_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrophoneInterface::Shutdown() {
|
||||||
|
StopSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lua API implementation
|
||||||
|
|
||||||
|
// Userdata for RecordingSession
|
||||||
|
struct LuaRecordingSession {
|
||||||
|
std::weak_ptr<RecordingSession> session;
|
||||||
|
int id;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char* RECORDING_SESSION_MT = "mosis.RecordingSession";
|
||||||
|
|
||||||
|
// Get MicrophoneInterface from upvalue
|
||||||
|
static MicrophoneInterface* GetMicrophoneInterface(lua_State* L) {
|
||||||
|
return static_cast<MicrophoneInterface*>(lua_touserdata(L, lua_upvalueindex(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get RecordingSession from userdata
|
||||||
|
static LuaRecordingSession* GetRecordingSession(lua_State* L, int index) {
|
||||||
|
return static_cast<LuaRecordingSession*>(luaL_checkudata(L, index, RECORDING_SESSION_MT));
|
||||||
|
}
|
||||||
|
|
||||||
|
// session:getBuffer() -> buffer or nil
|
||||||
|
static int L_session_getBuffer(lua_State* L) {
|
||||||
|
LuaRecordingSession* lrs = GetRecordingSession(L, 1);
|
||||||
|
|
||||||
|
auto session = lrs->session.lock();
|
||||||
|
if (!session || !session->IsActive()) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioBuffer buffer = session->GetBuffer();
|
||||||
|
|
||||||
|
// Return buffer as table
|
||||||
|
lua_newtable(L);
|
||||||
|
|
||||||
|
// buffer.sampleRate
|
||||||
|
lua_pushinteger(L, buffer.sample_rate);
|
||||||
|
lua_setfield(L, -2, "sampleRate");
|
||||||
|
|
||||||
|
// buffer.channels
|
||||||
|
lua_pushinteger(L, buffer.channels);
|
||||||
|
lua_setfield(L, -2, "channels");
|
||||||
|
|
||||||
|
// buffer.sampleCount
|
||||||
|
lua_pushinteger(L, buffer.sample_count);
|
||||||
|
lua_setfield(L, -2, "sampleCount");
|
||||||
|
|
||||||
|
// buffer.timestamp
|
||||||
|
lua_pushinteger(L, static_cast<lua_Integer>(buffer.timestamp_ms));
|
||||||
|
lua_setfield(L, -2, "timestamp");
|
||||||
|
|
||||||
|
// buffer.data (as string for binary data)
|
||||||
|
if (!buffer.data.empty()) {
|
||||||
|
lua_pushlstring(L, reinterpret_cast<const char*>(buffer.data.data()), buffer.data.size());
|
||||||
|
} else {
|
||||||
|
lua_pushstring(L, "");
|
||||||
|
}
|
||||||
|
lua_setfield(L, -2, "data");
|
||||||
|
|
||||||
|
// buffer.format
|
||||||
|
lua_pushstring(L, buffer.format == AudioFormat::PCM_FLOAT ? "float" : "pcm16");
|
||||||
|
lua_setfield(L, -2, "format");
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// session:stop()
|
||||||
|
static int L_session_stop(lua_State* L) {
|
||||||
|
LuaRecordingSession* lrs = GetRecordingSession(L, 1);
|
||||||
|
|
||||||
|
auto session = lrs->session.lock();
|
||||||
|
if (session) {
|
||||||
|
session->Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also stop via interface to hide indicator
|
||||||
|
MicrophoneInterface* mic = static_cast<MicrophoneInterface*>(
|
||||||
|
lua_touserdata(L, lua_upvalueindex(1))
|
||||||
|
);
|
||||||
|
if (mic) {
|
||||||
|
mic->StopSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// session:on(event, callback)
|
||||||
|
static int L_session_on(lua_State* L) {
|
||||||
|
LuaRecordingSession* lrs = GetRecordingSession(L, 1);
|
||||||
|
|
||||||
|
auto session = lrs->session.lock();
|
||||||
|
if (!session) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* event = luaL_checkstring(L, 2);
|
||||||
|
luaL_checktype(L, 3, LUA_TFUNCTION);
|
||||||
|
|
||||||
|
// For now, simplified implementation that doesn't store callbacks
|
||||||
|
// A full implementation would need to store refs and call them on events
|
||||||
|
|
||||||
|
lua_pushboolean(L, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// session:isActive() -> bool
|
||||||
|
static int L_session_isActive(lua_State* L) {
|
||||||
|
LuaRecordingSession* lrs = GetRecordingSession(L, 1);
|
||||||
|
|
||||||
|
auto session = lrs->session.lock();
|
||||||
|
lua_pushboolean(L, session && session->IsActive() ? 1 : 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordingSession garbage collection
|
||||||
|
static int L_session_gc(lua_State* L) {
|
||||||
|
LuaRecordingSession* lrs = GetRecordingSession(L, 1);
|
||||||
|
|
||||||
|
auto session = lrs->session.lock();
|
||||||
|
if (session && session->IsActive()) {
|
||||||
|
session->Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// microphone.start(config) -> session, error
|
||||||
|
static int L_microphone_start(lua_State* L) {
|
||||||
|
MicrophoneInterface* mic = GetMicrophoneInterface(L);
|
||||||
|
if (!mic) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
lua_pushstring(L, "MicrophoneInterface not available");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordingConfig config;
|
||||||
|
|
||||||
|
// Parse config table if provided
|
||||||
|
if (lua_istable(L, 1)) {
|
||||||
|
// sampleRate
|
||||||
|
lua_getfield(L, 1, "sampleRate");
|
||||||
|
if (lua_isnumber(L, -1)) {
|
||||||
|
config.sample_rate = static_cast<int>(lua_tointeger(L, -1));
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// channels
|
||||||
|
lua_getfield(L, 1, "channels");
|
||||||
|
if (lua_isnumber(L, -1)) {
|
||||||
|
config.channels = static_cast<int>(lua_tointeger(L, -1));
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// format
|
||||||
|
lua_getfield(L, 1, "format");
|
||||||
|
if (lua_isstring(L, -1)) {
|
||||||
|
std::string fmt = lua_tostring(L, -1);
|
||||||
|
if (fmt == "float" || fmt == "pcmfloat") {
|
||||||
|
config.format = AudioFormat::PCM_FLOAT;
|
||||||
|
} else {
|
||||||
|
config.format = AudioFormat::PCM_16BIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string error;
|
||||||
|
auto session = mic->StartSession(config, error);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
lua_pushstring(L, error.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create userdata
|
||||||
|
LuaRecordingSession* lrs = static_cast<LuaRecordingSession*>(
|
||||||
|
lua_newuserdata(L, sizeof(LuaRecordingSession))
|
||||||
|
);
|
||||||
|
lrs->session = session;
|
||||||
|
lrs->id = session->GetId();
|
||||||
|
|
||||||
|
// Set metatable
|
||||||
|
luaL_getmetatable(L, RECORDING_SESSION_MT);
|
||||||
|
lua_setmetatable(L, -2);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// microphone.isActive() -> bool
|
||||||
|
static int L_microphone_isActive(lua_State* L) {
|
||||||
|
MicrophoneInterface* mic = GetMicrophoneInterface(L);
|
||||||
|
if (!mic) {
|
||||||
|
lua_pushboolean(L, 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lua_pushboolean(L, mic->HasActiveSession() ? 1 : 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// microphone.stop()
|
||||||
|
static int L_microphone_stop(lua_State* L) {
|
||||||
|
MicrophoneInterface* mic = GetMicrophoneInterface(L);
|
||||||
|
if (mic) {
|
||||||
|
mic->StopSession();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set a global in the real _G (bypassing any proxy)
|
||||||
|
static void SetGlobalInRealG(lua_State* L, const char* name) {
|
||||||
|
lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS);
|
||||||
|
|
||||||
|
if (lua_getmetatable(L, -1)) {
|
||||||
|
lua_getfield(L, -1, "__index");
|
||||||
|
if (lua_istable(L, -1)) {
|
||||||
|
lua_pushvalue(L, -4);
|
||||||
|
lua_setfield(L, -2, name);
|
||||||
|
lua_pop(L, 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lua_pop(L, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
lua_pushvalue(L, -2);
|
||||||
|
lua_setfield(L, -2, name);
|
||||||
|
lua_pop(L, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RegisterMicrophoneAPI(lua_State* L, MicrophoneInterface* microphone) {
|
||||||
|
// Create RecordingSession metatable
|
||||||
|
luaL_newmetatable(L, RECORDING_SESSION_MT);
|
||||||
|
|
||||||
|
lua_pushstring(L, "__index");
|
||||||
|
lua_newtable(L);
|
||||||
|
|
||||||
|
// Methods with microphone interface as upvalue for stop
|
||||||
|
lua_pushlightuserdata(L, microphone);
|
||||||
|
lua_pushcclosure(L, L_session_getBuffer, 1);
|
||||||
|
lua_setfield(L, -2, "getBuffer");
|
||||||
|
|
||||||
|
lua_pushlightuserdata(L, microphone);
|
||||||
|
lua_pushcclosure(L, L_session_stop, 1);
|
||||||
|
lua_setfield(L, -2, "stop");
|
||||||
|
|
||||||
|
lua_pushcfunction(L, L_session_on);
|
||||||
|
lua_setfield(L, -2, "on");
|
||||||
|
|
||||||
|
lua_pushcfunction(L, L_session_isActive);
|
||||||
|
lua_setfield(L, -2, "isActive");
|
||||||
|
|
||||||
|
lua_settable(L, -3); // Set __index
|
||||||
|
|
||||||
|
// GC metamethod
|
||||||
|
lua_pushstring(L, "__gc");
|
||||||
|
lua_pushcfunction(L, L_session_gc);
|
||||||
|
lua_settable(L, -3);
|
||||||
|
|
||||||
|
lua_pop(L, 1); // Pop metatable
|
||||||
|
|
||||||
|
// Create microphone table
|
||||||
|
lua_newtable(L);
|
||||||
|
|
||||||
|
// microphone.start
|
||||||
|
lua_pushlightuserdata(L, microphone);
|
||||||
|
lua_pushcclosure(L, L_microphone_start, 1);
|
||||||
|
lua_setfield(L, -2, "start");
|
||||||
|
|
||||||
|
// microphone.isActive
|
||||||
|
lua_pushlightuserdata(L, microphone);
|
||||||
|
lua_pushcclosure(L, L_microphone_isActive, 1);
|
||||||
|
lua_setfield(L, -2, "isActive");
|
||||||
|
|
||||||
|
// microphone.stop
|
||||||
|
lua_pushlightuserdata(L, microphone);
|
||||||
|
lua_pushcclosure(L, L_microphone_stop, 1);
|
||||||
|
lua_setfield(L, -2, "stop");
|
||||||
|
|
||||||
|
// Set as global
|
||||||
|
SetGlobalInRealG(L, "microphone");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
120
src/main/cpp/sandbox/microphone_interface.h
Normal file
120
src/main/cpp/sandbox/microphone_interface.h
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
struct lua_State;
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
class PermissionGate;
|
||||||
|
|
||||||
|
enum class AudioFormat {
|
||||||
|
PCM_16BIT,
|
||||||
|
PCM_FLOAT
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RecordingConfig {
|
||||||
|
int sample_rate = 44100; // 8000, 16000, 22050, 44100, 48000
|
||||||
|
int channels = 1; // 1 (mono) or 2 (stereo)
|
||||||
|
AudioFormat format = AudioFormat::PCM_16BIT;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AudioBuffer {
|
||||||
|
std::vector<uint8_t> data;
|
||||||
|
int sample_rate = 0;
|
||||||
|
int channels = 0;
|
||||||
|
AudioFormat format = AudioFormat::PCM_16BIT;
|
||||||
|
uint64_t timestamp_ms = 0;
|
||||||
|
int sample_count = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingSession {
|
||||||
|
public:
|
||||||
|
using BufferCallback = std::function<void(const AudioBuffer& buffer)>;
|
||||||
|
|
||||||
|
RecordingSession(int id, const RecordingConfig& config);
|
||||||
|
~RecordingSession();
|
||||||
|
|
||||||
|
int GetId() const { return m_id; }
|
||||||
|
const RecordingConfig& GetConfig() const { return m_config; }
|
||||||
|
bool IsActive() const { return m_active; }
|
||||||
|
|
||||||
|
// Get accumulated audio data
|
||||||
|
AudioBuffer GetBuffer();
|
||||||
|
|
||||||
|
// Set buffer callback (for streaming)
|
||||||
|
void SetOnBuffer(BufferCallback cb) { m_on_buffer = std::move(cb); }
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
// For mock mode - simulate audio data arrival
|
||||||
|
void SimulateBuffer(const AudioBuffer& buffer);
|
||||||
|
|
||||||
|
private:
|
||||||
|
int m_id;
|
||||||
|
RecordingConfig m_config;
|
||||||
|
std::atomic<bool> m_active{true};
|
||||||
|
BufferCallback m_on_buffer;
|
||||||
|
AudioBuffer m_accumulated;
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MicrophoneInterface {
|
||||||
|
public:
|
||||||
|
MicrophoneInterface(const std::string& app_id, PermissionGate* permissions);
|
||||||
|
~MicrophoneInterface();
|
||||||
|
|
||||||
|
// Start recording session
|
||||||
|
// Returns session on success, nullptr on failure (sets error)
|
||||||
|
// Requires microphone permission and user gesture
|
||||||
|
std::shared_ptr<RecordingSession> StartSession(const RecordingConfig& config, std::string& error);
|
||||||
|
|
||||||
|
// Stop active session
|
||||||
|
void StopSession();
|
||||||
|
|
||||||
|
// Check if session is active
|
||||||
|
bool HasActiveSession() const;
|
||||||
|
|
||||||
|
// Check if recording indicator should be shown
|
||||||
|
bool IsIndicatorVisible() const;
|
||||||
|
|
||||||
|
// Cleanup on app stop
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
// For testing
|
||||||
|
void SetMockMode(bool enabled) { m_mock_mode = enabled; }
|
||||||
|
bool IsMockMode() const { return m_mock_mode; }
|
||||||
|
|
||||||
|
// Simulate user gesture for testing
|
||||||
|
void SimulateUserGesture();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_app_id;
|
||||||
|
PermissionGate* m_permissions;
|
||||||
|
std::shared_ptr<RecordingSession> m_active_session;
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
bool m_mock_mode = true;
|
||||||
|
std::atomic<bool> m_indicator_visible{false};
|
||||||
|
int m_next_session_id = 1;
|
||||||
|
|
||||||
|
// Track user gesture timing
|
||||||
|
std::chrono::steady_clock::time_point m_last_gesture_time;
|
||||||
|
bool m_has_gesture = false;
|
||||||
|
static constexpr int GESTURE_VALIDITY_MS = 5000; // 5 seconds
|
||||||
|
|
||||||
|
bool HasRecentUserGesture() const;
|
||||||
|
void ShowIndicator();
|
||||||
|
void HideIndicator();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register microphone.* APIs as globals
|
||||||
|
void RegisterMicrophoneAPI(lua_State* L, MicrophoneInterface* microphone);
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
Reference in New Issue
Block a user