Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions ios/HelloModule.swift

This file was deleted.

7 changes: 0 additions & 7 deletions ios/HelloModuleBridge.m

This file was deleted.

41 changes: 36 additions & 5 deletions pycontroller/app/(tabs)/audio.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import React from 'react';
import { View, Text } from 'react-native';
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { startSineStream, stopSineStream } from '../../src/audio/sineStreamer';


export default function AudioTab() {
// No way to detect audio devices without native code in React Native
const [playing, setPlaying] = useState(false);

const handlePlay = () => {
startSineStream();
setPlaying(true);
};

const handlePause = () => {
stopSineStream();
setPlaying(false);
};

return (
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16, color: '#fff' }}>Audio </Text>
<Text style={{ fontSize: 18, color: '#39ff14' }}>Coming soon</Text>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16, color: '#fff' }}>Audio</Text>
<View style={{ flexDirection: 'row', gap: 16 }}>
<TouchableOpacity
style={{ backgroundColor: playing ? '#444' : '#39ff14', padding: 16, borderRadius: 8, marginRight: 8 }}
onPress={handlePlay}
disabled={playing}
>
<Text style={{ color: '#000', fontWeight: 'bold', fontSize: 18 }}>Play</Text>
</TouchableOpacity>
<TouchableOpacity
style={{ backgroundColor: !playing ? '#444' : '#ff3939', padding: 16, borderRadius: 8, marginLeft: 8 }}
onPress={handlePause}
disabled={!playing}
>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 18 }}>Pause</Text>
</TouchableOpacity>
</View>
<Text style={{ fontSize: 18, color: '#39ff14', marginTop: 32 }}>
{playing ? 'Sine waves streaming...' : 'Press Play to start audio'}
</Text>
</View>
);
}
138 changes: 138 additions & 0 deletions pycontroller/sensorlib/ios/AudioModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import ExpoModulesCore

public class AudioEngineModule: Module {
public func definition() -> ModuleDefinition {
Name("AudioEngine")

AsyncFunction("pushSamples") { (samples: [Double]) in
let floatSamples = samples.map { Float($0) }
AudioEngine.shared.pushSamples(floatSamples)
}

AsyncFunction("getBufferedSamples") { () -> Int in
return AudioEngine.shared.getBufferedSamples()
}
}
}

// MARK: - AudioEngine Singleton
import AVFoundation

class AudioEngine {
static let shared = AudioEngine()
let ringBuffer = FloatRingBuffer(capacity: 48000 * 2) // ~2 seconds at 48kHz
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private let sampleRate: Double = 48000
private let bufferSize: AVAudioFrameCount = 1024
private var isPlaying = false

private init() {
setupAudio()
}

private func setupAudio() {
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: format)
try? engine.start()
playerNode.play()
isPlaying = true
scheduleBuffers()
}

private func scheduleBuffers() {
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
func scheduleNext() {
guard isPlaying else { return }
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize)!
let frames = ringBuffer.read(count: Int(bufferSize))
if frames.count < Int(bufferSize) {
// underrun: fill with silence
for i in frames.count..<Int(bufferSize) {
buffer.floatChannelData!.pointee[i] = 0.0
}
}
for i in 0..<frames.count {
buffer.floatChannelData!.pointee[i] = frames[i]
}
buffer.frameLength = bufferSize
playerNode.scheduleBuffer(buffer, completionHandler: scheduleNext)
}
scheduleNext()
}

func pushSamples(_ samples: [Float]) {
ringBuffer.write(samples)
}

func getBufferedSamples() -> Int {
return ringBuffer.count
}

func play() {
if !isPlaying {
playerNode.play()
isPlaying = true
scheduleBuffers()
}
}

func pause() {
if isPlaying {
playerNode.pause()
isPlaying = false
}
}
}

// MARK: - Lock-free FloatRingBuffer
class FloatRingBuffer {
private var buffer: [Float]
private let capacity: Int
private var writeIndex: Int = 0
private var readIndex: Int = 0
private var count_: Int = 0
private let lock = DispatchSemaphore(value: 1)

init(capacity: Int) {
self.capacity = capacity
self.buffer = [Float](repeating: 0, count: capacity)
}

var count: Int {
return count_
}

func write(_ samples: [Float]) {
lock.wait()
defer { lock.signal() }
for sample in samples {
if count_ < capacity {
buffer[writeIndex] = sample
writeIndex = (writeIndex + 1) % capacity
count_ += 1
} else {
// overflow: drop extra samples
break
}
}
}

func read(count: Int) -> [Float] {
lock.wait()
defer { lock.signal() }
var out: [Float] = []
for _ in 0..<count {
if count_ > 0 {
out.append(buffer[readIndex])
readIndex = (readIndex + 1) % capacity
count_ -= 1
} else {
// underrun: output silence
out.append(0.0)
}
}
return out
}
}
140 changes: 133 additions & 7 deletions pycontroller/sensorlib/ios/SensorlibModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import CoreHaptics
import CoreHaptics
import ExpoModulesCore
import AVFoundation

// Error type for haptics
enum HapticError: Error {
case missingDuration
Expand Down Expand Up @@ -35,13 +37,10 @@ public class SensorlibModule: Module {
// Unified haptics play function
AsyncFunction("playHaptic") { (input: HapticPatternInput) in
let engine = try HapticsEngineManager.shared.getEngine()

var events: [CHHapticEvent] = []
var curves: [CHHapticParameterCurve] = []

let intensity = input.intensity ?? 1.0
let sharpness = input.sharpness ?? 0.5

switch input.type {
case "transient":
let event = CHHapticEvent(eventType: .hapticTransient,
Expand All @@ -63,7 +62,6 @@ public class SensorlibModule: Module {
relativeTime: 0,
duration: duration)
events.append(event)

if let curvePoints = input.curve {
var intensityCurvePoints: [CHHapticParameterCurve.ControlPoint] = []
var sharpnessCurvePoints: [CHHapticParameterCurve.ControlPoint] = []
Expand All @@ -83,14 +81,23 @@ public class SensorlibModule: Module {
}
}
default:
throw HapticError.unknownType(input.type)
throw HapticError.unknownType(input.type)
}

let pattern = try CHHapticPattern(events: events, parameterCurves: curves)
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: 0)
}

// Expose audio engine methods
AsyncFunction("pushAudioSamples") { (samples: [Double]) in
let floatSamples = samples.map { Float($0) }
AudioEngine.shared.pushSamples(floatSamples)
}

AsyncFunction("getAudioBufferedSamples") { () -> Int in
return AudioEngine.shared.getBufferedSamples()
}

// Enables the module to be used as a native view. Definition components that are accepted as part of the
// view definition: Prop, Events.
View(SensorlibView.self) {
Expand All @@ -100,8 +107,127 @@ public class SensorlibModule: Module {
view.webView.load(URLRequest(url: url))
}
}

Events("onLoad")
}
}
}

// MARK: - AudioEngine Singleton
class AudioEngine {
static let shared = AudioEngine()
let ringBuffer = FloatRingBuffer(capacity: 48000 * 2) // ~2 seconds at 48kHz
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private let sampleRate: Double = 48000
private let bufferSize: AVAudioFrameCount = 1024
private var isPlaying = false

private init() {
setupAudio()
}

private func setupAudio() {
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: format)
try? engine.start()
playerNode.play()
isPlaying = true
scheduleBuffers()
}

private func scheduleBuffers() {
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
func scheduleNext() {
guard isPlaying else { return }
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize)!
let frames = ringBuffer.read(count: Int(bufferSize))
if frames.count < Int(bufferSize) {
// underrun: fill with silence
for i in frames.count..<Int(bufferSize) {
buffer.floatChannelData!.pointee[i] = 0.0
}
}
for i in 0..<frames.count {
buffer.floatChannelData!.pointee[i] = frames[i]
}
buffer.frameLength = bufferSize
playerNode.scheduleBuffer(buffer, completionHandler: scheduleNext)
}
scheduleNext()
}

func pushSamples(_ samples: [Float]) {
ringBuffer.write(samples)
}

func getBufferedSamples() -> Int {
return ringBuffer.count
}

func play() {
if !isPlaying {
playerNode.play()
isPlaying = true
scheduleBuffers()
}
}

func pause() {
if isPlaying {
playerNode.pause()
isPlaying = false
}
}
}

// MARK: - Lock-free FloatRingBuffer
class FloatRingBuffer {
private var buffer: [Float]
private let capacity: Int
private var writeIndex: Int = 0
private var readIndex: Int = 0
private var count_: Int = 0
private let lock = DispatchSemaphore(value: 1)

init(capacity: Int) {
self.capacity = capacity
self.buffer = [Float](repeating: 0, count: capacity)
}

var count: Int {
return count_
}

func write(_ samples: [Float]) {
lock.wait()
defer { lock.signal() }
for sample in samples {
if count_ < capacity {
buffer[writeIndex] = sample
writeIndex = (writeIndex + 1) % capacity
count_ += 1
} else {
// overflow: drop extra samples
break
}
}
}

func read(count: Int) -> [Float] {
lock.wait()
defer { lock.signal() }
var out: [Float] = []
for _ in 0..<count {
if count_ > 0 {
out.append(buffer[readIndex])
readIndex = (readIndex + 1) % capacity
count_ -= 1
} else {
// underrun: output silence
out.append(0.0)
}
}
return out
}
}
Loading