Compare commits
2 Commits
6410477f54
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea5fc9e97d | ||
|
|
4bc3a0a096 |
3
.idea/deploymentTargetSelector.xml
generated
3
.idea/deploymentTargetSelector.xml
generated
@@ -13,6 +13,9 @@
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="ExampleInstrumentedTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package dev.adriankuta.visualizer
|
||||
|
||||
import android.media.audiofx.Visualizer
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.domain.SoundToColorMapper
|
||||
import dev.adriankuta.visualizer.domain.SoundToColorMapperFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Interface for audio visualization functionality.
|
||||
* Follows Interface Segregation Principle.
|
||||
*/
|
||||
interface AudioVisualizerService {
|
||||
val currentColor: State<Color>
|
||||
val isActive: State<Boolean>
|
||||
|
||||
fun start()
|
||||
fun stop()
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of AudioVisualizerService using Android's Visualizer API.
|
||||
*
|
||||
* Follows Single Responsibility Principle by focusing on audio visualization.
|
||||
* Uses Dependency Injection for the sound-to-color mapper.
|
||||
*/
|
||||
class AndroidAudioVisualizer(
|
||||
private val soundToColorMapper: SoundToColorMapper = SoundToColorMapperFactory.createDefault(),
|
||||
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
|
||||
) : AudioVisualizerService {
|
||||
|
||||
companion object {
|
||||
private const val SAMPLING_RATE = 16000 // Hz
|
||||
private const val CAPTURE_SIZE = 1024 // Window size for audio capture
|
||||
}
|
||||
|
||||
private var visualizer: Visualizer? = null
|
||||
private var processingJob: Job? = null
|
||||
|
||||
private val _currentColor = mutableStateOf(Color.Black)
|
||||
override val currentColor: State<Color> = _currentColor
|
||||
|
||||
private val _isActive = mutableStateOf(false)
|
||||
override val isActive: State<Boolean> = _isActive
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
// Initialize visualizer with audio session ID 0 (output mix)
|
||||
visualizer = Visualizer(0).apply {
|
||||
captureSize = CAPTURE_SIZE
|
||||
|
||||
// Set up waveform data listener
|
||||
setDataCaptureListener(
|
||||
object : Visualizer.OnDataCaptureListener {
|
||||
override fun onWaveFormDataCapture(
|
||||
visualizer: Visualizer?,
|
||||
waveform: ByteArray?,
|
||||
samplingRate: Int
|
||||
) {
|
||||
waveform?.let { processWaveform(it) }
|
||||
}
|
||||
|
||||
override fun onFftDataCapture(
|
||||
visualizer: Visualizer?,
|
||||
fft: ByteArray?,
|
||||
samplingRate: Int
|
||||
) {
|
||||
// Not used in this implementation
|
||||
}
|
||||
},
|
||||
Visualizer.getMaxCaptureRate() / 2, // Capture rate
|
||||
true, // Waveform
|
||||
false // FFT
|
||||
)
|
||||
|
||||
enabled = true
|
||||
}
|
||||
|
||||
_isActive.value = true
|
||||
Timber.d("AudioVisualizer started successfully")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to start AudioVisualizer")
|
||||
_isActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
try {
|
||||
visualizer?.enabled = false
|
||||
processingJob?.cancel()
|
||||
_isActive.value = false
|
||||
Timber.d("AudioVisualizer stopped")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error stopping AudioVisualizer")
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
stop()
|
||||
try {
|
||||
visualizer?.release()
|
||||
visualizer = null
|
||||
soundToColorMapper.reset()
|
||||
Timber.d("AudioVisualizer released")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error releasing AudioVisualizer")
|
||||
}
|
||||
}
|
||||
|
||||
private fun processWaveform(waveform: ByteArray) {
|
||||
processingJob?.cancel()
|
||||
processingJob = coroutineScope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
val color = soundToColorMapper.mapSoundToColor(waveform)
|
||||
|
||||
// Update UI on main thread
|
||||
launch(Dispatchers.Main) {
|
||||
_currentColor.value = color
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error processing waveform")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating AudioVisualizerService instances.
|
||||
* Follows Factory Pattern and makes testing easier.
|
||||
*/
|
||||
object AudioVisualizerFactory {
|
||||
|
||||
/**
|
||||
* Creates a default Android audio visualizer.
|
||||
*/
|
||||
fun createDefault(coroutineScope: CoroutineScope): AudioVisualizerService {
|
||||
return AndroidAudioVisualizer(
|
||||
soundToColorMapper = SoundToColorMapperFactory.createDefault(),
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Android audio visualizer with custom mapper.
|
||||
*/
|
||||
fun create(
|
||||
soundToColorMapper: SoundToColorMapper,
|
||||
coroutineScope: CoroutineScope
|
||||
): AudioVisualizerService {
|
||||
return AndroidAudioVisualizer(soundToColorMapper, coroutineScope)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package dev.adriankuta.visualizer.data
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AudioVisualizer: AudioVisualizerController {
|
||||
fun waveform(): Flow<WaveformFrame>
|
||||
|
||||
fun fft(): Flow<FftFrame>
|
||||
fun visualizerFlow(): Flow<VisualizerFrame>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package dev.adriankuta.visualizer.data
|
||||
|
||||
import android.media.audiofx.Visualizer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import javax.inject.Inject
|
||||
|
||||
class AudioVisualizerImpl @Inject constructor(
|
||||
@@ -13,22 +14,14 @@ class AudioVisualizerImpl @Inject constructor(
|
||||
) : AudioVisualizer {
|
||||
|
||||
override fun start() {
|
||||
visualizer.enabled = true
|
||||
// visualizer.enabled = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
visualizer.enabled = false
|
||||
}
|
||||
|
||||
override fun waveform(): Flow<WaveformFrame> =
|
||||
visualizerFlow()
|
||||
.filterIsInstance(WaveformFrame::class)
|
||||
|
||||
override fun fft(): Flow<FftFrame> =
|
||||
visualizerFlow()
|
||||
.filterIsInstance(FftFrame::class)
|
||||
|
||||
private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
|
||||
override fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
|
||||
val listener = object : Visualizer.OnDataCaptureListener {
|
||||
override fun onFftDataCapture(
|
||||
visualizer: Visualizer?,
|
||||
@@ -47,13 +40,15 @@ class AudioVisualizerImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
visualizer.setDataCaptureListener(
|
||||
val result = visualizer.setDataCaptureListener(
|
||||
listener,
|
||||
Visualizer.getMaxCaptureRate(),
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
visualizer.enabled = true
|
||||
|
||||
awaitClose {
|
||||
runCatching {
|
||||
visualizer.enabled = false
|
||||
@@ -63,13 +58,17 @@ class AudioVisualizerImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
.conflate()
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
|
||||
sealed interface VisualizerFrame {
|
||||
val data: ByteArray
|
||||
}
|
||||
|
||||
sealed interface VisualizerFrame
|
||||
data class WaveformFrame(
|
||||
val data: ByteArray,
|
||||
override val data: ByteArray,
|
||||
) : VisualizerFrame
|
||||
|
||||
data class FftFrame(
|
||||
val data: ByteArray,
|
||||
override val data: ByteArray,
|
||||
) : VisualizerFrame
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.adriankuta.visualizer.data
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RmsValueProvider @Inject constructor() {
|
||||
|
||||
private val currentRms =
|
||||
MutableSharedFlow<RmsValue>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun emit(value: RmsValue) {
|
||||
currentRms.tryEmit(value)
|
||||
}
|
||||
|
||||
fun observe(): SharedFlow<RmsValue> = currentRms
|
||||
}
|
||||
|
||||
data class RmsValue(
|
||||
val current: Double,
|
||||
val max: Double,
|
||||
val min: Double = 0.0
|
||||
)
|
||||
@@ -9,6 +9,8 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dev.adriankuta.visualizer.data.AudioVisualizer
|
||||
import dev.adriankuta.visualizer.data.AudioVisualizerController
|
||||
import dev.adriankuta.visualizer.data.AudioVisualizerImpl
|
||||
import dev.adriankuta.visualizer.domain.processors.HSLSoundToColorMapper
|
||||
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -25,6 +27,11 @@ abstract class VisualizerModule {
|
||||
audioVisualizerImpl: AudioVisualizerImpl
|
||||
): AudioVisualizerController
|
||||
|
||||
@Binds
|
||||
abstract fun bindProcessor(
|
||||
fftToColor: HSLSoundToColorMapper
|
||||
): VisualizerProcessor
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -33,5 +40,11 @@ abstract class VisualizerModule {
|
||||
captureSize = 1024
|
||||
}
|
||||
}
|
||||
|
||||
/*@Provides
|
||||
@Singleton
|
||||
fun provideProcessor(): VisualizerProcessor {
|
||||
return HSLSoundToColorMapper()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,28 @@ package dev.adriankuta.visualizer.domain
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.data.AudioVisualizer
|
||||
import dev.adriankuta.visualizer.data.FftFrame
|
||||
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||
import dev.adriankuta.visualizer.data.WaveformFrame
|
||||
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import javax.inject.Inject
|
||||
|
||||
class ObserveVisualizerColorUseCase @Inject constructor(
|
||||
private val audioVisualizer: AudioVisualizer
|
||||
private val audioVisualizer: AudioVisualizer,
|
||||
private val processor: VisualizerProcessor,
|
||||
) {
|
||||
|
||||
private val colorMapper = HSLSoundToColorMapper()
|
||||
|
||||
operator fun invoke(): Flow<Color> =
|
||||
audioVisualizer.waveform()
|
||||
.map { processColor(it) }
|
||||
|
||||
private fun processColor(waveformFrame: WaveformFrame): Color {
|
||||
return colorMapper.mapSoundToColor(waveformFrame.data)
|
||||
}
|
||||
audioVisualizer.visualizerFlow()
|
||||
.process(processor)
|
||||
.onStart { emit(Color.Black) }
|
||||
}
|
||||
|
||||
fun Flow<VisualizerFrame>.process(processor: VisualizerProcessor) =
|
||||
this.filterIsInstance(WaveformFrame::class)
|
||||
.map(processor::invoke)
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package dev.adriankuta.visualizer.domain
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.HSL
|
||||
import dev.adriankuta.visualizer.MovingAverage
|
||||
import dev.adriankuta.visualizer.calculateRms
|
||||
|
||||
/**
|
||||
* Interface for mapping sound data to colors.
|
||||
* Follows the Interface Segregation Principle by defining a focused contract.
|
||||
*/
|
||||
interface SoundToColorMapper {
|
||||
/**
|
||||
* Maps audio waveform data to a color.
|
||||
*
|
||||
* @param waveform Audio waveform data as byte array
|
||||
* @return Compose Color representing the mapped sound
|
||||
*/
|
||||
fun mapSoundToColor(waveform: ByteArray): Color
|
||||
|
||||
/**
|
||||
* Resets the internal state of the mapper.
|
||||
*/
|
||||
fun reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of SoundToColorMapper that uses HSL color space
|
||||
* and moving average for smooth color transitions.
|
||||
*
|
||||
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
|
||||
* Follows Dependency Inversion Principle by depending on abstractions.
|
||||
*/
|
||||
class HSLSoundToColorMapper(
|
||||
private val baseHue: Float = 0f, // Blue hue as default
|
||||
private val saturation: Float = 1f,
|
||||
private val movingAverageWindowSize: Int = 1
|
||||
) : SoundToColorMapper {
|
||||
|
||||
private val movingAverage = MovingAverage(movingAverageWindowSize)
|
||||
private var rmsMax = 0.0
|
||||
|
||||
override fun mapSoundToColor(waveform: ByteArray): Color {
|
||||
// Calculate RMS value for current frame
|
||||
val currentRms = calculateRms(waveform)
|
||||
|
||||
// Update maximum RMS value for normalization
|
||||
if (currentRms > rmsMax) {
|
||||
rmsMax = currentRms
|
||||
}
|
||||
|
||||
// Calculate energy ratio (0-1)
|
||||
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
|
||||
|
||||
// Apply moving average for smooth transitions
|
||||
val adjustedLightness = movingAverage.process(energyRatio)
|
||||
|
||||
// Create HSL color and convert to Compose Color
|
||||
val hslColor = HSL(
|
||||
hue = baseHue,
|
||||
saturation = saturation,
|
||||
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
|
||||
)
|
||||
|
||||
return hslColor.toComposeColor()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
movingAverage.reset()
|
||||
rmsMax = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating SoundToColorMapper instances.
|
||||
* Follows the Factory Pattern and Open/Closed Principle.
|
||||
*/
|
||||
object SoundToColorMapperFactory {
|
||||
|
||||
/**
|
||||
* Creates a default HSL-based sound to color mapper.
|
||||
*/
|
||||
fun createDefault(): SoundToColorMapper = HSLSoundToColorMapper()
|
||||
|
||||
/**
|
||||
* Creates an HSL-based sound to color mapper with custom parameters.
|
||||
*
|
||||
* @param baseHue The base hue for the color (0-360)
|
||||
* @param saturation The saturation level (0-1)
|
||||
* @param movingAverageWindowSize Window size for moving average smoothing
|
||||
*/
|
||||
fun createHSLMapper(
|
||||
baseHue: Float = 240f,
|
||||
saturation: Float = 1f,
|
||||
movingAverageWindowSize: Int = 7
|
||||
): SoundToColorMapper = HSLSoundToColorMapper(baseHue, saturation, movingAverageWindowSize)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package dev.adriankuta.visualizer.domain.processors
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
||||
import dev.adriankuta.visualizer.data.FftFrame
|
||||
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||
import dev.adriankuta.visualizer.data.WaveformFrame
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Shared utilities for brightness mapping.
|
||||
*/
|
||||
class BrightnessMapper(
|
||||
private val hue: Float = 210f, // blue-ish
|
||||
private val saturation: Float = 0.9f, // vivid color
|
||||
private val gain: Float = 8f, // log mapping strength (higher -> more sensitive)
|
||||
private val attack: Float = 0.35f, // 0..1, higher = faster rise
|
||||
private val release: Float = 0.08f, // 0..1, lower = slower fall
|
||||
private val peakDecay: Float = 0.995f // 0..1, how fast auto-gain reference decays
|
||||
) {
|
||||
private var smoothed = 0f
|
||||
private var peak = 0.1f
|
||||
|
||||
/**
|
||||
* @param rawEnergy input in [0, +inf) (e.g., RMS or avg magnitude)
|
||||
* @return Color with brightness (V) in [0,1]
|
||||
*/
|
||||
fun toColor(rawEnergy: Float): Color {
|
||||
// 1) Log loudness mapping: map [0, +inf) → [0, 1]
|
||||
// normalizedLog = ln(1 + gain * x) / ln(1 + gain)
|
||||
val loud = if (rawEnergy <= 0f) 0f
|
||||
else (ln(1f + gain * rawEnergy) / ln(1f + gain))
|
||||
|
||||
// 2) Peak/auto-gain tracking
|
||||
peak = max(loud, peak * peakDecay)
|
||||
val norm = if (peak > 1e-6f) loud / peak else loud
|
||||
val clamped = norm.coerceIn(0f, 1f)
|
||||
|
||||
// 3) Attack/Release smoothing
|
||||
val a = if (clamped > smoothed) attack else release
|
||||
smoothed = smoothed + a * (clamped - smoothed)
|
||||
|
||||
// 4) HSV → Color (fixed hue & saturation; dynamic value)
|
||||
return Color.hsv(
|
||||
hue = hue.coerceIn(0f, 360f),
|
||||
saturation = saturation.coerceIn(0f, 1f),
|
||||
value = smoothed.coerceIn(0f, 1f),
|
||||
colorSpace = ColorSpaces.Srgb
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualizer waveform → brightness.
|
||||
* Visualizer's waveform bytes are unsigned 8-bit: 0..255 with 128 ≈ 0.
|
||||
*/
|
||||
class WaveformToColorProcessor @Inject constructor(
|
||||
) : VisualizerProcessor {
|
||||
private val brightness: BrightnessMapper = BrightnessMapper(
|
||||
hue = 210f,
|
||||
saturation = 0.9f,
|
||||
gain = 8f,
|
||||
attack = 0.35f,
|
||||
release = 0.08f,
|
||||
peakDecay = 0.995f
|
||||
)
|
||||
|
||||
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||
val bytes = frame.data
|
||||
if (bytes.isEmpty()) {
|
||||
return@withContext Color.Black
|
||||
}
|
||||
|
||||
// Convert to [-1, 1] floats, compute RMS.
|
||||
var sumSq = 0.0
|
||||
val inv = 1f / 128f
|
||||
for (b in bytes) {
|
||||
// unsigned -> center at 128 -> normalize
|
||||
val s = ((b.toInt() and 0xFF) - 128) * inv
|
||||
sumSq += (s * s)
|
||||
}
|
||||
val rms = sqrt(sumSq / bytes.size).toFloat() // 0..~1
|
||||
|
||||
brightness.toColor(rms)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualizer FFT → brightness.
|
||||
* Visualizer's FFT bytes are 8-bit signed fixed-point: interleaved (re, im).
|
||||
* We compute mean magnitude over bins. You can bias ranges by adding weights.
|
||||
*/
|
||||
class FftToColorProcessor @Inject constructor(
|
||||
) : VisualizerProcessor {
|
||||
private val brightness: BrightnessMapper = BrightnessMapper(
|
||||
hue = 210f,
|
||||
saturation = 0.9f,
|
||||
gain = 10f,
|
||||
attack = 0.30f,
|
||||
release = 0.10f,
|
||||
peakDecay = 0.996f
|
||||
)
|
||||
private val binWeighting: ((binIndex: Int, totalBins: Int) -> Float)? = null
|
||||
|
||||
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||
val bytes = frame.data
|
||||
if (bytes.size < 2) {
|
||||
return@withContext Color.Black
|
||||
}
|
||||
|
||||
// Each bin: (re, im) as signed bytes. Skip DC/nyquist if you like; here we include all valid pairs.
|
||||
val pairCount = bytes.size / 2
|
||||
var sumMag = 0.0
|
||||
var weightSum = 0.0
|
||||
|
||||
var bin = 0
|
||||
var i = 0
|
||||
while (i + 1 < bytes.size) {
|
||||
val re = bytes[i].toInt().toByte().toInt() // ensure sign
|
||||
val im = bytes[i + 1].toInt().toByte().toInt()
|
||||
val mag = sqrt((re * re + im * im).toDouble()) // magnitude in byte units
|
||||
val w = binWeighting?.invoke(bin, pairCount)?.coerceAtLeast(0f)?.toDouble() ?: 1.0
|
||||
sumMag += mag * w
|
||||
weightSum += w
|
||||
bin += 1
|
||||
i += 2
|
||||
}
|
||||
|
||||
// Average magnitude → normalize to something close to [0,1].
|
||||
// 8-bit range ≈ [-128,127], so typical magnitude can be up to ~181; average is far lower.
|
||||
val avgMag = if (weightSum > 0.0) sumMag / weightSum else 0.0
|
||||
val norm = (avgMag / 128.0).toFloat().coerceAtLeast(0f) // rough normalization
|
||||
|
||||
brightness.toColor(norm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.adriankuta.visualizer.domain.processors
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.data.FftFrame
|
||||
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class FftToColor @Inject constructor() : VisualizerProcessor {
|
||||
|
||||
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
|
||||
// --- Extract raw FFT bytes from your frame ---
|
||||
// Adjust the property name if your FftFrame differs (e.g., `frame.data`, `frame.bytes`, etc.)
|
||||
val fft: ByteArray = frame.data
|
||||
|
||||
if (fft.isEmpty()) return@withContext Color.Black
|
||||
|
||||
// Visualizer FFT format: interleaved [re0, im0, re1, im1, ...]
|
||||
// Skip DC (bin 0: re0, im0) as it often behaves like noise/offset.
|
||||
var sumMagnitude = 0.0
|
||||
var bins = 0
|
||||
|
||||
var i = 2 // start from bin 1 (skip DC)
|
||||
while (i + 1 < fft.size) {
|
||||
val re = fft[i].toInt()
|
||||
val im = fft[i + 1].toInt()
|
||||
val mag = sqrt((re * re + im * im).toDouble())
|
||||
sumMagnitude += mag
|
||||
bins++
|
||||
i += 2
|
||||
}
|
||||
|
||||
val avgMagnitude = if (bins > 0) sumMagnitude / bins else 0.0
|
||||
|
||||
// Normalize ~[0..1]. Each component is in [-128..127], so magnitude upper bound ~181.
|
||||
// 128 is a practical normalization for stable visuals; then clamp.
|
||||
val normalized = (avgMagnitude / 128.0).coerceIn(0.0, 1.0)
|
||||
|
||||
// Perceptual easing to avoid flicker and give more dynamic range at lower levels.
|
||||
val eased = sqrt(normalized).toFloat()
|
||||
|
||||
// Simple noise gate so tiny ambient levels don’t glow.
|
||||
val value = if (eased < NOISE_FLOOR) 0f else eased
|
||||
|
||||
// Single, stable color – only brightness changes with the music.
|
||||
Color.hsv(BASE_HUE, BASE_SATURATION, value)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Tune these to your taste (degrees 0..360, 0..1).
|
||||
const val BASE_HUE = 0f // cool blue
|
||||
const val BASE_SATURATION = 0.85f // vivid
|
||||
const val NOISE_FLOOR = 0.05f // ignore very low energy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.adriankuta.visualizer.domain.processors
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.HSL
|
||||
import dev.adriankuta.visualizer.MovingAverage
|
||||
import dev.adriankuta.visualizer.calculateRms
|
||||
import dev.adriankuta.visualizer.data.RmsValue
|
||||
import dev.adriankuta.visualizer.data.RmsValueProvider
|
||||
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of SoundToColorMapper that uses HSL color space
|
||||
* and moving average for smooth color transitions.
|
||||
*
|
||||
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
|
||||
* Follows Dependency Inversion Principle by depending on abstractions.
|
||||
*/
|
||||
class HSLSoundToColorMapper @Inject constructor(
|
||||
private val rmsValueProvider: RmsValueProvider
|
||||
) : VisualizerProcessor {
|
||||
private val baseHue: Float = 0f // Blue hue as default
|
||||
private val saturation: Float = 1f
|
||||
private val movingAverageWindowSize: Int = 2
|
||||
|
||||
private val movingAverage = MovingAverage(movingAverageWindowSize)
|
||||
private var rmsMax = 0.0
|
||||
|
||||
override suspend fun invoke(frame: VisualizerFrame): Color {
|
||||
val waveform = frame.data
|
||||
// Calculate RMS value for current frame
|
||||
val currentRms = calculateRms(waveform)
|
||||
|
||||
rmsMax -= 0.01
|
||||
// Update maximum RMS value for normalization
|
||||
if (currentRms > rmsMax) {
|
||||
rmsMax = currentRms
|
||||
}
|
||||
rmsValueProvider.emit(RmsValue(currentRms, rmsMax))
|
||||
|
||||
Timber.d("Max RMS: %s, current %s", rmsMax, currentRms)
|
||||
|
||||
// Calculate energy ratio (0-1)
|
||||
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
|
||||
|
||||
// Apply moving average for smooth transitions
|
||||
val adjustedLightness = movingAverage.process(energyRatio)
|
||||
|
||||
// Create HSL color and convert to Compose Color
|
||||
val hslColor = HSL(
|
||||
hue = baseHue,
|
||||
saturation = saturation,
|
||||
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
|
||||
)
|
||||
|
||||
return hslColor.toComposeColor()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.adriankuta.visualizer.domain.processors
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.data.VisualizerFrame
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
interface VisualizerProcessor {
|
||||
|
||||
suspend operator fun invoke(frame: VisualizerFrame): Color
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.adriankuta.visualizer.ui.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
@Composable
|
||||
fun KeepScreenOn() {
|
||||
val activity = LocalActivity.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
onDispose {
|
||||
// Clear the flag when the composable is disposed
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,40 @@ import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -24,35 +50,21 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.adriankuta.visualizer.AudioVisualizerFactory
|
||||
import dev.adriankuta.visualizer.AudioVisualizerService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import dev.adriankuta.visualizer.data.RmsValue
|
||||
import dev.adriankuta.visualizer.ui.components.KeepScreenOn
|
||||
import timber.log.Timber
|
||||
|
||||
|
||||
@Composable
|
||||
fun VisualizerScreen(
|
||||
viewModel: VisualizerScreenViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
}
|
||||
@Composable
|
||||
fun VisualizerScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
audioVisualizer: AudioVisualizerService = remember {
|
||||
AudioVisualizerFactory.createDefault(
|
||||
CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
)
|
||||
}
|
||||
viewModel: VisualizerScreenViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentColor by audioVisualizer.currentColor
|
||||
val isActive by audioVisualizer.isActive
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
KeepScreenOn()
|
||||
|
||||
// Permission state management
|
||||
var hasAudioPermission by remember {
|
||||
@@ -73,7 +85,7 @@ fun VisualizerScreen(
|
||||
hasAudioPermission = isGranted
|
||||
if (isGranted) {
|
||||
Timber.d("Audio permission granted, starting visualizer")
|
||||
audioVisualizer.start()
|
||||
viewModel.start()
|
||||
} else {
|
||||
Timber.w("Audio permission denied")
|
||||
showPermissionRationale = true
|
||||
@@ -84,8 +96,9 @@ fun VisualizerScreen(
|
||||
fun startVisualizerWithPermissionCheck() {
|
||||
when {
|
||||
hasAudioPermission -> {
|
||||
audioVisualizer.start()
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d("Requesting audio permission")
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
@@ -98,7 +111,7 @@ fun VisualizerScreen(
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck()
|
||||
Lifecycle.Event.ON_STOP -> audioVisualizer.stop()
|
||||
Lifecycle.Event.ON_STOP -> viewModel.stop()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +120,7 @@ fun VisualizerScreen(
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
audioVisualizer.release()
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,19 +140,15 @@ fun VisualizerScreen(
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
// Color display area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(300.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(currentColor)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
// Color display and RMS progress bar area
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Show color information
|
||||
ColorInfoCard(
|
||||
color = currentColor,
|
||||
isActive = isActive
|
||||
ColorDisplay(uiState)
|
||||
RmsProgressBar(
|
||||
rmsValue = uiState.rmsValue,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,7 +156,7 @@ fun VisualizerScreen(
|
||||
|
||||
// Status indicator
|
||||
StatusIndicator(
|
||||
isActive = isActive,
|
||||
isActive = uiState.isActive,
|
||||
hasPermission = hasAudioPermission
|
||||
)
|
||||
|
||||
@@ -157,7 +166,7 @@ fun VisualizerScreen(
|
||||
Text(
|
||||
text = when {
|
||||
!hasAudioPermission -> "Audio permission is required to visualize sound"
|
||||
isActive -> "Play some music or make sounds to see the colors change!"
|
||||
uiState.isActive -> "Play some music or make sounds to see the colors change!"
|
||||
else -> "Starting audio visualizer..."
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
@@ -211,13 +220,35 @@ fun VisualizerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorDisplay(uiState: VisualizerScreenUiState) {
|
||||
val animatedColor by animateColorAsState(
|
||||
uiState.color,
|
||||
animationSpec = tween(50),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(300.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.drawBehind {
|
||||
drawRect(animatedColor)
|
||||
}
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Show color information
|
||||
ColorInfoCard(
|
||||
color = uiState.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card displaying color information.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColorInfoCard(
|
||||
color: Color,
|
||||
isActive: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
@@ -298,3 +329,101 @@ private fun StatusIndicator(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical progress bar showing RMS values.
|
||||
*/
|
||||
@Composable
|
||||
private fun RmsProgressBar(
|
||||
rmsValue: RmsValue?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "RMS Level",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (rmsValue != null) {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = if (rmsValue.max > 0) {
|
||||
(rmsValue.current / rmsValue.max).coerceIn(0.0, 1.0).toFloat()
|
||||
} else {
|
||||
0f
|
||||
},
|
||||
animationSpec = tween(50)
|
||||
)
|
||||
val animatedColor by animateColorAsState(
|
||||
when {
|
||||
animatedProgress > 0.8f -> Color.Red
|
||||
animatedProgress > 0.6f -> Color.Yellow
|
||||
else -> Color.Green
|
||||
},
|
||||
animationSpec = tween(50)
|
||||
)
|
||||
|
||||
// Vertical progress bar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.drawBehind {
|
||||
val fillHeight = size.height * animatedProgress
|
||||
drawRect(
|
||||
color = animatedColor,
|
||||
topLeft = Offset(0f, size.height - fillHeight),
|
||||
size = Size(size.width, fillHeight)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Show current value
|
||||
Text(
|
||||
text = String.format("%.3f", rmsValue.current),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
} else {
|
||||
// Placeholder when no RMS data
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No Data",
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package dev.adriankuta.visualizer.view
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.adriankuta.visualizer.data.RmsValue
|
||||
|
||||
data class VisualizerScreenUiState(
|
||||
val color: Color
|
||||
val color: Color,
|
||||
val isActive: Boolean = false,
|
||||
val rmsValue: RmsValue? = null
|
||||
)
|
||||
|
||||
@@ -1,23 +1,59 @@
|
||||
@file:OptIn(FlowPreview::class)
|
||||
|
||||
package dev.adriankuta.visualizer.view
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.adriankuta.visualizer.data.AudioVisualizerController
|
||||
import dev.adriankuta.visualizer.data.RmsValueProvider
|
||||
import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@HiltViewModel
|
||||
class VisualizerScreenViewModel @Inject constructor(
|
||||
private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase
|
||||
observeVisualizerColorUseCase: ObserveVisualizerColorUseCase,
|
||||
private val audioVisualizerController: AudioVisualizerController,
|
||||
private val rmsValueProvider: RmsValueProvider
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState = observeVisualizerColorUseCase()
|
||||
.stateIn(
|
||||
private val _isActive = MutableStateFlow(false)
|
||||
|
||||
val uiState = combine(
|
||||
observeVisualizerColorUseCase().sample(50.milliseconds),
|
||||
_isActive,
|
||||
rmsValueProvider.observe().sample(50.milliseconds)
|
||||
) { color, isActive, rmsValue ->
|
||||
VisualizerScreenUiState(
|
||||
color = color,
|
||||
isActive = isActive,
|
||||
rmsValue = rmsValue
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = Color.Black
|
||||
initialValue = VisualizerScreenUiState(
|
||||
color = Color.Black,
|
||||
isActive = false,
|
||||
rmsValue = null
|
||||
)
|
||||
)
|
||||
|
||||
fun start() {
|
||||
audioVisualizerController.start()
|
||||
_isActive.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
audioVisualizerController.stop()
|
||||
_isActive.value = false
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package dev.adriankuta.visualizer
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user