diff --git a/ios/HelloModule.swift b/ios/HelloModule.swift deleted file mode 100644 index fb7ca67..0000000 --- a/ios/HelloModule.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -@objc(HelloModule) -class HelloModule: NSObject { - @objc - func getHelloWorld(_ callback: @escaping RCTResponseSenderBlock) { - callback(["Hello world from Swift"]) - } -} diff --git a/ios/HelloModuleBridge.m b/ios/HelloModuleBridge.m deleted file mode 100644 index c3158ef..0000000 --- a/ios/HelloModuleBridge.m +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(HelloModule, NSObject) - -RCT_EXTERN_METHOD(getHelloWorld:(RCTResponseSenderBlock)callback) - -@end diff --git a/pycontroller/app/(tabs)/audio.tsx b/pycontroller/app/(tabs)/audio.tsx index 26ad043..11f793c 100644 --- a/pycontroller/app/(tabs)/audio.tsx +++ b/pycontroller/app/(tabs)/audio.tsx @@ -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 ( - Audio - Coming soon + Audio + + + Play + + + Pause + + + + {playing ? 'Sine waves streaming...' : 'Press Play to start audio'} + ); } diff --git a/pycontroller/sensorlib/ios/AudioModule.swift b/pycontroller/sensorlib/ios/AudioModule.swift new file mode 100644 index 0000000..e88e192 --- /dev/null +++ b/pycontroller/sensorlib/ios/AudioModule.swift @@ -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 { + 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.. 0 { + out.append(buffer[readIndex]) + readIndex = (readIndex + 1) % capacity + count_ -= 1 + } else { + // underrun: output silence + out.append(0.0) + } + } + return out + } +} diff --git a/pycontroller/sensorlib/ios/SensorlibModule.swift b/pycontroller/sensorlib/ios/SensorlibModule.swift index 0a50a64..918baeb 100644 --- a/pycontroller/sensorlib/ios/SensorlibModule.swift +++ b/pycontroller/sensorlib/ios/SensorlibModule.swift @@ -2,6 +2,8 @@ import CoreHaptics import CoreHaptics import ExpoModulesCore +import AVFoundation + // Error type for haptics enum HapticError: Error { case missingDuration @@ -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, @@ -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] = [] @@ -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) { @@ -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 { + 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.. 0 { + out.append(buffer[readIndex]) + readIndex = (readIndex + 1) % capacity + count_ -= 1 + } else { + // underrun: output silence + out.append(0.0) + } + } + return out + } +} diff --git a/pycontroller/src/audio/sineStreamer.js b/pycontroller/src/audio/sineStreamer.js new file mode 100644 index 0000000..2e3e71e --- /dev/null +++ b/pycontroller/src/audio/sineStreamer.js @@ -0,0 +1,39 @@ +import { requireNativeModule } from 'expo-modules-core'; +const Sensorlib = requireNativeModule('Sensorlib'); + +const SAMPLE_RATE = 48000; +const CHUNK_SIZE = 2048; +const FREQ = 440; +let phase = 0; +let running = false; + +function generateConstantChunk(value = 0.5) { + const chunk = new Float32Array(CHUNK_SIZE); + for (let i = 0; i < CHUNK_SIZE; i++) { + chunk[i] = value; + } + return chunk; +} + +async function sineLoop() { + while (running) { + const buffered = await Sensorlib.getAudioBufferedSamples(); + const seconds = buffered / SAMPLE_RATE; + let timeout = 20; + if (seconds < 0.3) timeout = 10; + else if (seconds > 0.8) timeout = 40; + const chunk = generateConstantChunk(); + Sensorlib.pushAudioSamples(Array.from(chunk)); + await new Promise(resolve => setTimeout(resolve, timeout)); + } +} + +export function startSineStream() { + if (running) return; + running = true; + sineLoop(); +} + +export function stopSineStream() { + running = false; +}