diff --git a/.gitignore b/.gitignore index 3bc4011..2bf3da5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,11 @@ example modules/*/android/build/ modules/*/android/.gradle/ modules/*/android/.cxx/ + +# vendored kotlin-audio fork — built from source by the root Gradle build vendor/kotlinaudio/kotlin-audio/build/ +vendor/kotlinaudio/kotlin-audio/.gradle/ +vendor/kotlinaudio/kotlin-audio/.cxx/ HANDOFF.md DESIGN.md diff --git a/modules/astra-library-scanner/android/build.gradle b/modules/astra-library-scanner/android/build.gradle index 4498825..165986b 100644 --- a/modules/astra-library-scanner/android/build.gradle +++ b/modules/astra-library-scanner/android/build.gradle @@ -16,3 +16,10 @@ android { abortOnError false } } + +dependencies { + // ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for + // MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without + // decoding audio. Transitively brings exoplayer-extractor (the frame classes). + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0' +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index c40576e..7aedcb6 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -12,9 +12,19 @@ import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.provider.DocumentsContract +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MetadataRetriever +import com.google.android.exoplayer2.metadata.id3.InternalFrame +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame +import com.google.android.exoplayer2.metadata.flac.VorbisComment import java.nio.ByteOrder +import java.util.concurrent.TimeUnit +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.log10 import kotlin.math.max import kotlin.math.sqrt +import kotlin.math.tan import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module @@ -38,6 +48,21 @@ class FileRequest : Record { @Field val coverUri: String? = null } +/** Result of one scan-time decode: waveform peaks + integrated loudness + sample peak. */ +class AudioAnalysis : Record { + @Field var peaks: FloatArray = FloatArray(0) + @Field var lufs: Double? = null // integrated LUFS (negative dB); null if unmeasured + @Field var peak: Double? = null // absolute sample peak, linear [0,1]; null if unmeasured +} + +/** ReplayGain tags read from the container (no audio decode). Null = tag absent. */ +class ReplayGainTags : Record { + @Field var trackGainDb: Double? = null // REPLAYGAIN_TRACK_GAIN (dB) + @Field var albumGainDb: Double? = null // REPLAYGAIN_ALBUM_GAIN (dB) + @Field var trackPeak: Double? = null // REPLAYGAIN_TRACK_PEAK (linear, ~[0,1+]) + @Field var albumPeak: Double? = null // REPLAYGAIN_ALBUM_PEAK (linear, ~[0,1+]) +} + class AstraLibraryScannerModule : Module() { private val artworkThumbSize = 128 @@ -72,10 +97,25 @@ class AstraLibraryScannerModule : Module() { // run lazily per track on the JS side; results are cached in SQLite there. AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int -> waveformSemaphore.withPermit { - withContext(Dispatchers.IO) { extractWaveform(uri, if (bins > 0) bins else 512) } + withContext(Dispatchers.IO) { decodeAndAnalyze(uri, if (bins > 0) bins else 512).peaks } } } + // Fast loudness (M4): decodes only a few short windows spread across the track + // (not the whole file) + gated K-weighting -> integrated LUFS + sample peak. + // Waveform peaks stay lazy/full-decode (extractWaveform), decoupled from this. + AsyncFunction("measureLoudness") Coroutine { uri: String -> + waveformSemaphore.withPermit { + withContext(Dispatchers.IO) { measureLoudness(uri) } + } + } + + // ReplayGain tags (M4): reads container metadata only (no PCM decode), so it is + // cheap and lets us normalize a tagged library without the slow loudness decode. + AsyncFunction("readReplayGain") Coroutine { uri: String -> + withContext(Dispatchers.IO) { readReplayGain(uri) } + } + Function("getArtworkDirPath") { artworkDir().absolutePath } @@ -127,6 +167,100 @@ class AstraLibraryScannerModule : Module() { private fun artworkThumbDir(): File = File(requireContext().filesDir, "artwork-thumbs").apply { if (!exists()) mkdirs() } + // --------------------------------------------------------------------------- + // ReplayGain tags (container metadata only — no audio decode) + // --------------------------------------------------------------------------- + + // Cap MetadataRetriever per file so a malformed/huge container can't hang a worker. + private val metadataTimeoutMs = 12_000L + + /** + * Read ReplayGain track/album gain (dB) + peak (linear) from container tags via + * ExoPlayer's MetadataRetriever (parses ID3 TXXX, Vorbis comments, MP4 freeform + * atoms without decoding PCM). Mirrors the desktop's extractReplayGainDb fuzzy + * matching. Returns all-null on any failure (unsupported container, IO, timeout). + */ + private fun readReplayGain(uriStr: String): ReplayGainTags { + val result = ReplayGainTags() + try { + val mediaItem = MediaItem.fromUri(Uri.parse(uriStr)) + val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem) + .get(metadataTimeoutMs, TimeUnit.MILLISECONDS) + + // R128 (Opus / EBU) is a fallback used only when no REPLAYGAIN_* tag is present. + var r128Track: Double? = null + var r128Album: Double? = null + + fun consider(rawKey: String?, rawValue: String?) { + if (rawKey == null || rawValue == null) return + val key = normalizeRgKey(rawKey) + when { + result.trackGainDb == null && (key.contains("replaygain_track_gain") || key.contains("rg_track_gain")) -> + result.trackGainDb = parseRgDb(rawValue) + result.albumGainDb == null && (key.contains("replaygain_album_gain") || key.contains("rg_album_gain")) -> + result.albumGainDb = parseRgDb(rawValue) + result.trackPeak == null && (key.contains("replaygain_track_peak") || key.contains("rg_track_peak")) -> + result.trackPeak = parsePeak(rawValue) + result.albumPeak == null && (key.contains("replaygain_album_peak") || key.contains("rg_album_peak")) -> + result.albumPeak = parsePeak(rawValue) + r128Track == null && key.contains("r128_track_gain") -> r128Track = parseR128(rawValue) + r128Album == null && key.contains("r128_album_gain") -> r128Album = parseR128(rawValue) + } + } + + for (g in 0 until trackGroups.length) { + val group = trackGroups.get(g) + for (f in 0 until group.length) { + val metadata = group.getFormat(f).metadata ?: continue + for (i in 0 until metadata.length()) { + when (val entry = metadata.get(i)) { + // ID3 user-defined text frame: description is the key, value the text. + is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value) + // FLAC/Ogg/Opus Vorbis comments (vorbis.VorbisComment extends this). + is VorbisComment -> consider(entry.key, entry.value) + // MP4 iTunes freeform "----:com.apple.iTunes:replaygain_*" atoms. + is InternalFrame -> consider(entry.description, entry.text) + } + } + } + } + + if (result.trackGainDb == null) result.trackGainDb = r128Track + if (result.albumGainDb == null) result.albumGainDb = r128Album + } catch (_: Throwable) { + // Unsupported container, IO error, or timeout -> no tags. + } + return result + } + + private fun normalizeRgKey(id: String): String = + id.trim().lowercase().replace(Regex("[\\s-]+"), "_") + + /** Parse a ReplayGain dB value like "-6.54 dB", "-6,54", or "+3.2". */ + private fun parseRgDb(raw: String): Double? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + trimmed.toDoubleOrNull()?.let { return it } + trimmed.replace(Regex("(?i)\\s*dB\\s*$"), "").trim().toDoubleOrNull()?.let { return it } + val m = Regex("[+-]?\\d+(?:[.,]\\d+)?").find(trimmed) ?: return null + return m.value.replace(',', '.').toDoubleOrNull() + } + + /** Parse a ReplayGain peak (linear amplitude, > 0). */ + private fun parsePeak(raw: String): Double? { + val trimmed = raw.trim() + trimmed.toDoubleOrNull()?.let { return if (it > 0.0) it else null } + val m = Regex("[+-]?\\d+(?:[.,]\\d+)?").find(trimmed) ?: return null + val v = m.value.replace(',', '.').toDoubleOrNull() ?: return null + return if (v > 0.0) v else null + } + + /** R128_*_GAIN is Q7.8 dB relative to -23 LUFS; +5 dB realigns to the RG reference. */ + private fun parseR128(raw: String): Double? { + val v = raw.trim().toIntOrNull() ?: return null + return v / 256.0 + 5.0 + } + // --------------------------------------------------------------------------- // Directory walk // --------------------------------------------------------------------------- @@ -303,11 +437,12 @@ class AstraLibraryScannerModule : Module() { // Waveform peaks (offline RMS bins) // --------------------------------------------------------------------------- - // Decodes the whole file to PCM and accumulates RMS energy per bin (mirrors - // desktop waveformExtractor.extractWaveformPeaks), normalized to [0,1]. Returns - // an empty array on any failure (caller falls back to a flat seek bar). - private fun extractWaveform(uriStr: String, bins: Int): FloatArray { + // One whole-file PCM decode -> per-bin RMS waveform peaks (normalized [0,1]) for the + // seek bar. Returns empty peaks on any failure (caller falls back to a flat seek + // bar). Loudness is measured separately by measureLoudness. + private fun decodeAndAnalyze(uriStr: String, bins: Int): AudioAnalysis { val context = requireContext() + val result = AudioAnalysis() val uri = Uri.parse(uriStr) val extractor = MediaExtractor() var codec: MediaCodec? = null @@ -322,7 +457,7 @@ class AstraLibraryScannerModule : Module() { trackFormat = f; trackIndex = i; break } } - val format = trackFormat ?: return FloatArray(0) + val format = trackFormat ?: return result extractor.selectTrack(trackIndex) val sampleRate = @@ -370,7 +505,7 @@ class AstraLibraryScannerModule : Module() { out.position(info.offset) out.limit(info.offset + info.size) out.order(ByteOrder.nativeOrder()) - frame = accumulate(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts) + frame = accumulateAnalyze(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts) } codec.releaseOutputBuffer(outIndex, false) } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { @@ -394,9 +529,10 @@ class AstraLibraryScannerModule : Module() { if (globalMax > 0) { for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat() } - return peaks + result.peaks = peaks + return result } catch (_: Throwable) { - return FloatArray(0) + return result } finally { try { codec?.stop() } catch (_: Throwable) {} try { codec?.release() } catch (_: Throwable) {} @@ -404,9 +540,128 @@ class AstraLibraryScannerModule : Module() { } } - // Folds one decoded PCM buffer into the per-bin RMS accumulators. Handles + // Integrated gated loudness over the WHOLE file (accurate — subset sampling caused + // too much loudness inconsistency). Decodes the full track and feeds the gated + // K-weighting meter. Measured on the fly per track (current + queue lookahead) and + // cached, so the cost is paid once per track, never in a bulk background pass. + private fun measureLoudness(uriStr: String): AudioAnalysis { + val context = requireContext() + val result = AudioAnalysis() + val uri = Uri.parse(uriStr) + val extractor = MediaExtractor() + var codec: MediaCodec? = null + try { + extractor.setDataSource(context, uri, null) + + var trackFormat: MediaFormat? = null + var trackIndex = -1 + for (i in 0 until extractor.trackCount) { + val f = extractor.getTrackFormat(i) + if (f.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) { + trackFormat = f; trackIndex = i; break + } + } + val format = trackFormat ?: return result + extractor.selectTrack(trackIndex) + + val sampleRate = + if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 + var channelCount = + if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2 + var pcmFloat = false + + codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!) + codec.configure(format, null, null, 0) + codec.start() + val info = MediaCodec.BufferInfo() + + var meter: LoudnessMeter? = null + var sawInputEOS = false + var sawOutputEOS = false + while (!sawOutputEOS) { + if (!sawInputEOS) { + val inIndex = codec.dequeueInputBuffer(10_000) + if (inIndex >= 0) { + val inBuf = codec.getInputBuffer(inIndex)!! + val size = extractor.readSampleData(inBuf, 0) + if (size < 0) { + codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + sawInputEOS = true + } else { + codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } + } + val outIndex = codec.dequeueOutputBuffer(info, 10_000) + if (outIndex >= 0) { + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) sawOutputEOS = true + if (info.size > 0) { + val out = codec.getOutputBuffer(outIndex)!! + out.position(info.offset) + out.limit(info.offset + info.size) + out.order(ByteOrder.nativeOrder()) + val m = meter ?: LoudnessMeter(channelCount, sampleRate).also { meter = it } + feedMeter(out, pcmFloat, channelCount, m) + } + codec.releaseOutputBuffer(outIndex, false) + } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + val nf = codec.outputFormat + if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT + } + } + } + + meter?.let { + result.lufs = it.lufs() + result.peak = it.peak + } + return result + } catch (_: Throwable) { + return result + } finally { + try { codec?.stop() } catch (_: Throwable) {} + try { codec?.release() } catch (_: Throwable) {} + try { extractor.release() } catch (_: Throwable) {} + } + } + + // Feeds one decoded PCM buffer (16-bit or float) to the loudness meter. + private fun feedMeter( + out: java.nio.ByteBuffer, + pcmFloat: Boolean, + channelCount: Int, + meter: LoudnessMeter + ) { + if (pcmFloat) { + val fb = out.asFloatBuffer() + val n = fb.remaining() + var k = 0 + while (k < n) { + var c = 0 + while (c < channelCount && k < n) { + meter.process(fb.get(k).toDouble(), c); k++; c++ + } + } + } else { + val sb = out.asShortBuffer() + val n = sb.remaining() + var k = 0 + while (k < n) { + var c = 0 + while (c < channelCount && k < n) { + meter.process(sb.get(k) / 32768.0, c); k++; c++ + } + } + } + } + + // Folds one decoded PCM buffer into the per-bin RMS accumulators and, when a + // loudness meter is provided, the K-weighted loudness + sample peak. Handles // 16-bit (default) and float PCM. Returns the updated running frame index. - private fun accumulate( + private fun accumulateAnalyze( out: java.nio.ByteBuffer, pcmFloat: Boolean, channelCount: Int, @@ -451,6 +706,111 @@ class AstraLibraryScannerModule : Module() { return frame } + // Gated integrated K-weighted loudness per ITU-R BS.1770 + absolute sample peak. + // Two cascaded biquads (high-shelf pre-filter + RLB high-pass) per channel with + // pyloudnorm-reference coefficients (so the -0.691 offset holds), accumulated into + // 400 ms blocks, then a two-stage gate (-70 LUFS absolute, -10 LU relative). Unity + // channel weights (fine for mono/stereo). Non-overlapping blocks (vs the spec's 75% + // overlap) — within ~0.1 LU and much cheaper. + private class LoudnessMeter(private val channels: Int, sampleRate: Int) { + private val b0a: Double; private val b1a: Double; private val b2a: Double + private val a1a: Double; private val a2a: Double + private val a1b: Double; private val a2b: Double + + private val s1a = DoubleArray(channels) + private val s2a = DoubleArray(channels) + private val s1b = DoubleArray(channels) + private val s2b = DoubleArray(channels) + + private val blockSumSq = DoubleArray(channels) + private val blockFrames: Int + private var framesInBlock = 0 + // Per-block summed-channel mean-square energy (z), for gating. + private val blockEnergies = ArrayList() + + var peak: Double = 0.0 + private set + + init { + val fs = sampleRate.coerceAtLeast(1).toDouble() + // Stage 1: high-shelf pre-filter. + val f0a = 1681.974450955533 + val ga = 3.999843853973347 + val qa = 0.7071752369554196 + val ka = tan(PI * f0a / fs) + val vh = Math.pow(10.0, ga / 20.0) + val vb = Math.pow(vh, 0.4996667741545416) + val a0a = 1.0 + ka / qa + ka * ka + b0a = (vh + vb * ka / qa + ka * ka) / a0a + b1a = 2.0 * (ka * ka - vh) / a0a + b2a = (vh - vb * ka / qa + ka * ka) / a0a + a1a = 2.0 * (ka * ka - 1.0) / a0a + a2a = (1.0 - ka / qa + ka * ka) / a0a + // Stage 2: RLB high-pass (b = [1, -2, 1]). + val f0b = 38.13547087602444 + val qb = 0.5003270373238773 + val kb = tan(PI * f0b / fs) + val a0b = 1.0 + kb / qb + kb * kb + a1b = 2.0 * (kb * kb - 1.0) / a0b + a2b = (1.0 - kb / qb + kb * kb) / a0b + + blockFrames = max(1L, (0.4 * fs).toLong()).toInt() // 400 ms gating block + } + + fun process(sample: Double, ch: Int) { + if (ch >= channels) return + val a = abs(sample) + if (a > peak) peak = a + // Stage 1 (transposed direct form II). + val y1 = b0a * sample + s1a[ch] + s1a[ch] = b1a * sample - a1a * y1 + s2a[ch] + s2a[ch] = b2a * sample - a2a * y1 + // Stage 2: b0=1, b1=-2, b2=1. + val y2 = y1 + s1b[ch] + s1b[ch] = -2.0 * y1 - a1b * y2 + s2b[ch] + s2b[ch] = y1 - a2b * y2 + blockSumSq[ch] += y2 * y2 + // One frame completes when the last channel of the frame is processed. + if (ch == channels - 1) { + framesInBlock++ + if (framesInBlock >= blockFrames) finalizeBlock() + } + } + + private fun finalizeBlock() { + if (framesInBlock <= 0) return + var energy = 0.0 + for (c in 0 until channels) { + energy += blockSumSq[c] / framesInBlock + blockSumSq[c] = 0.0 + } + framesInBlock = 0 + if (energy > 0.0) blockEnergies.add(energy) + } + + fun lufs(): Double { + finalizeBlock() // flush the trailing partial block + if (blockEnergies.isEmpty()) return -70.0 + + // Absolute gate at -70 LUFS (energy terms). + val absThresh = Math.pow(10.0, (-70.0 + 0.691) / 10.0) + var sum = 0.0 + var cnt = 0 + for (e in blockEnergies) if (e >= absThresh) { sum += e; cnt++ } + if (cnt == 0) return -70.0 + + // Relative gate: -10 LU below the abs-gated mean. + val relLoudness = -0.691 + 10.0 * log10(sum / cnt) + val relThresh = Math.pow(10.0, (relLoudness - 10.0 + 0.691) / 10.0) + sum = 0.0 + cnt = 0 + for (e in blockEnergies) if (e >= absThresh && e >= relThresh) { sum += e; cnt++ } + if (cnt == 0) return -70.0 + + return -0.691 + 10.0 * log10(sum / cnt) + } + } + private fun readBitsPerSample(format: MediaFormat): Int? { // The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs // may expose a PCM encoding instead. Both are best-effort. diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 7905e4a..ce330c4 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -43,6 +43,14 @@ export interface ExtractedMetadata { artworkHash?: string | null; } +/** ReplayGain tags read from a file's container (null = tag absent). */ +export interface ReplayGainTags { + trackGainDb: number | null; + albumGainDb: number | null; + trackPeak: number | null; + albumPeak: number | null; +} + export interface ScanProgressEvent { phase: 'discovering'; found: number; @@ -60,6 +68,18 @@ declare class AstraLibraryScannerModuleType extends NativeModule; + /** + * Fast integrated loudness (M4): decodes only a few short windows across the + * track + gated K-weighting -> integrated LUFS + absolute sample peak. Null on + * failure / unmeasurable audio. Waveform peaks are separate (extractWaveform). + */ + measureLoudness(uri: string): Promise<{ lufs: number | null; peak: number | null }>; + /** + * Read ReplayGain track/album gain (dB) + peak (linear) from container tags + * (ID3 TXXX / Vorbis comments / MP4 freeform) without decoding audio. All fields + * are null when the tag is absent; the whole call is cheap (metadata only). + */ + readReplayGain(uri: string): Promise; getArtworkDirPath(): string; getArtworkThumbDirPath(): string; ensureArtworkThumbnails(hashes: string[]): Promise; diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt index eac67fc..0e2c329 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt @@ -29,5 +29,52 @@ class AstraScopeModule : Module() { Function("getOscilloscopeFrame") { out: Float32Array -> ScopeBridge.nativeFillOscilloscope(out.toDirectBuffer(), out.length) } + + // --- M4: post-EQ spectrum (EQ screen overlay) --- + // Gate the post-EQ tap (true only while the EQ screen is open). + Function("setActivePostEq") { active: Boolean -> + ScopeBridge.postEqActive = active + } + + Function("getSpectrumFramePostEq") { out: Float32Array -> + ScopeBridge.nativeFillSpectrumPostEq(out.toDirectBuffer(), out.length) + } + + // --- M4: EQ params + per-track gain (consumed by the kotlin-audio processors) --- + Function("setEqEnabled") { enabled: Boolean -> + EqBridge.enabled = enabled + EqBridge.revision += 1 + } + + Function("setEqPreamp") { linear: Double -> + EqBridge.preampLinear = linear.toFloat() + EqBridge.revision += 1 + } + + // Flat band params: 5 floats per band [typeOrdinal, frequency, gain, Q, enabled?1:0]. + Function("setEqBands") { params: FloatArray -> + EqBridge.bands = params + EqBridge.revision += 1 + } + + Function("setNormalizationGain") { linear: Double -> + GainBridge.linearGain = linear.toFloat() + } + + // Register a queued track's gain by URL so the player can switch to it natively at + // the exact media-item transition (no JS round-trip on track change). + Function("setTrackGain") { url: String, linear: Double -> + GainBridge.putGain(url, linear.toFloat()) + } + + // Make the registered gain for this URL active now (used for the current track on + // mount / settings change, where no transition fires). + Function("activateTrackGain") { url: String -> + GainBridge.activateFor(url) + } + + Function("clearTrackGains") { + GainBridge.clearGains() + } } } diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/EqBridge.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/EqBridge.kt new file mode 100644 index 0000000..49348e2 --- /dev/null +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/EqBridge.kt @@ -0,0 +1,29 @@ +package expo.modules.astrascope + +/** + * Holds the current parametric-EQ configuration, set from JS (raw band params, NOT + * biquad coefficients). The vendored kotlin-audio `EqAudioProcessor` reads these + * fields lock-free on the ExoPlayer audio thread and recomputes coefficients at the + * real stream sample rate whenever [revision] changes. + * + * Single writer (JS thread via [AstraScopeModule]); single reader (audio thread). + * `bands` is published before `revision` is bumped, so a reader that observes a new + * revision also observes the matching band array. + */ +object EqBridge { + /** Master bypass. When false the processor is passthrough. */ + @Volatile + var enabled: Boolean = false + + /** EQ preamp as a linear amplitude (1 = unity). */ + @Volatile + var preampLinear: Float = 1f + + /** 5 floats per band: [typeOrdinal, frequency, gain, Q, enabled?1:0]. */ + @Volatile + var bands: FloatArray = FloatArray(0) + + /** Bumped on every enabled/preamp/bands change so the processor recomputes. */ + @Volatile + var revision: Int = 0 +} diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt new file mode 100644 index 0000000..1a86334 --- /dev/null +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/GainBridge.kt @@ -0,0 +1,37 @@ +package expo.modules.astrascope + +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-track normalization / ReplayGain gain, read lock-free by the vendored + * kotlin-audio `NormalizationGainProcessor` on the audio thread. Applied BEFORE the + * scope taps so the scopes see normalized levels. + * + * JS pre-registers each queued track's gain by URL ([putGain]); the player then swaps + * the active gain to the matching one natively at the real media-item transition + * ([activateFor], called from BaseAudioPlayer.onMediaItemTransition). That lands the + * gain at the actual audio boundary instead of after a JS round-trip on track change. + */ +object GainBridge { + /** Active linear amplitude multiplier (1 = unity). Read on the audio thread. */ + @Volatile + var linearGain: Float = 1f + + // url -> linear gain, seeded from JS ahead of playback. + private val gains = ConcurrentHashMap() + + /** Register (or update) the gain for a track URL. */ + fun putGain(url: String, gain: Float) { + gains[url] = gain + } + + /** Make the gain registered for [url] active (unity if unknown/null). */ + fun activateFor(url: String?) { + linearGain = if (url != null) gains[url] ?: 1f else 1f + } + + /** Drop all registered gains (e.g. on full queue reset). */ + fun clearGains() { + gains.clear() + } +} diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt index b885e2a..aadccb9 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt @@ -21,6 +21,14 @@ object ScopeBridge { @Volatile var active: Boolean = false + /** + * Gates the POST-EQ tap specifically (true only while the EQ screen is open). + * The post-EQ tap runs when [active] && [postEqActive], so the second downmix + * costs nothing unless the EQ overlay is actually being viewed. + */ + @Volatile + var postEqActive: Boolean = false + /** Audio thread. Tell the analyzer the stream's sample rate / channels. */ external fun nativeConfigure(sampleRate: Int, channelCount: Int) @@ -41,4 +49,13 @@ object ScopeBridge { * writes straight into JS memory. */ external fun nativeFillOscilloscope(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int + + /** Audio thread. Push POST-EQ interleaved float PCM (the M4 second tap). */ + external fun nativePushFramesPostEq(frames: FloatArray, frameCount: Int, channelCount: Int) + + /** + * Render thread. Fill `buffer` with the latest POST-EQ dB spectrum (feeds the + * EQ screen's response-curve overlay). Returns bins written. Zero-copy. + */ + external fun nativeFillSpectrumPostEq(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int } diff --git a/modules/astra-scope/cpp/scope_jni.cpp b/modules/astra-scope/cpp/scope_jni.cpp index 5a7261f..33f6d8a 100644 --- a/modules/astra-scope/cpp/scope_jni.cpp +++ b/modules/astra-scope/cpp/scope_jni.cpp @@ -73,4 +73,38 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillOscilloscope( return static_cast(n); } +// --- POST-EQ source (M4) ---------------------------------------------------- +// The post-EQ tap pushes here; the EQ screen pulls the post-EQ spectrum. + +JNIEXPORT void JNICALL +Java_expo_modules_astrascope_ScopeBridge_nativePushFramesPostEq( + JNIEnv* env, jobject /*thiz*/, jfloatArray frames, jint frameCount, + jint channelCount) { + if (frames == nullptr || frameCount <= 0 || channelCount <= 0) { + return; + } + auto* data = static_cast( + env->GetPrimitiveArrayCritical(frames, nullptr)); + if (data == nullptr) { + return; + } + driver().pushInterleavedPostEq(data, static_cast(frameCount), + static_cast(channelCount)); + env->ReleasePrimitiveArrayCritical(frames, data, JNI_ABORT); +} + +JNIEXPORT jint JNICALL +Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrumPostEq( + JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats) { + if (buffer == nullptr || capacityFloats <= 0) { + return 0; + } + auto* dst = static_cast(env->GetDirectBufferAddress(buffer)); + if (dst == nullptr) { + return 0; + } + const size_t n = driver().fillSpectrumPostEq(dst, static_cast(capacityFloats)); + return static_cast(n); +} + } // extern "C" diff --git a/modules/astra-scope/cpp/scope_ring.h b/modules/astra-scope/cpp/scope_ring.h index d40cbed..b20a1f3 100644 --- a/modules/astra-scope/cpp/scope_ring.h +++ b/modules/astra-scope/cpp/scope_ring.h @@ -184,10 +184,70 @@ class ScopeDriver { return count; } + // ---- POST-EQ source (M4) ------------------------------------------------- + // A second, spectrum-only SPSC source fed by the post-EQ tap. Mirrors the + // pre-EQ spectrum path exactly; used only by the EQ screen's response-curve + // overlay, so there is no post-EQ oscilloscope. + + // Audio thread. Downmix interleaved float frames to mono into the post-EQ ring. + void pushInterleavedPostEq(const float* data, size_t frames, int channels) { + if (data == nullptr || frames == 0 || channels <= 0) { + return; + } + size_t w = postEqWritePos_.load(std::memory_order_relaxed); + const float inv = 1.0f / static_cast(channels); + for (size_t f = 0; f < frames; ++f) { + float sum = 0.0f; + const float* frame = data + f * channels; + for (int c = 0; c < channels; ++c) { + sum += frame[c]; + } + postEqRing_[w & kMask] = sum * inv; + ++w; + } + postEqWritePos_.store(w, std::memory_order_release); + } + + // Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes). + size_t fillSpectrumPostEq(float* out, size_t cap) { + if (out == nullptr || cap == 0) { + return 0; + } + + const int sr = pendingSampleRate_.load(std::memory_order_acquire); + if (sr != postEqAppliedSampleRate_) { + postEqSpectrum_.setSampleRate(static_cast(sr)); + postEqAppliedSampleRate_ = sr; + } + + const size_t fftSize = postEqSpectrum_.getFFTSize(); + const size_t w = postEqWritePos_.load(std::memory_order_acquire); + const size_t sampleRate = sr > 0 ? static_cast(sr) : static_cast(48000); + const size_t delaySamples = scopeOutputDelaySamples(sampleRate); + const size_t readHead = w > delaySamples ? w - delaySamples : 0; + + const std::vector* mags; + if (readHead >= fftSize) { + postEqScratch_.resize(fftSize); + const size_t start = readHead - fftSize; + for (size_t i = 0; i < fftSize; ++i) { + postEqScratch_[i] = postEqRing_[(start + i) & kMask]; + } + mags = &postEqSpectrum_.process(postEqScratch_.data(), fftSize); + } else { + mags = &postEqSpectrum_.process(nullptr, 0); + } + + const size_t n = std::min(cap, mags->size()); + std::memcpy(out, mags->data(), n * sizeof(float)); + return n; + } + size_t binCount() const { return spectrum_.getFFTSize() / 2; } void reset() { spectrum_.reset(); + postEqSpectrum_.reset(); osc_.reset(); oscReadPos_ = writePos_.load(std::memory_order_acquire); oscSamplesSeen_ = 0; @@ -196,9 +256,11 @@ class ScopeDriver { } private: - ScopeDriver() : spectrum_(kFftSize) { + ScopeDriver() : spectrum_(kFftSize), postEqSpectrum_(kFftSize) { spectrum_.setSmoothing(0.92f); + postEqSpectrum_.setSmoothing(0.92f); ring_.assign(kSize, 0.0f); + postEqRing_.assign(kSize, 0.0f); } static constexpr size_t kFftSize = 2048; // -> 1024 dB bins @@ -277,6 +339,13 @@ class ScopeDriver { Visualizer::Spectrum spectrum_; int appliedSampleRate_{0}; + // Post-EQ source (M4) — second SPSC ring + spectrum-only analyzer. + std::vector postEqRing_; + std::atomic postEqWritePos_{0}; + std::vector postEqScratch_; + Visualizer::Spectrum postEqSpectrum_; + int postEqAppliedSampleRate_{0}; + Visualizer::Oscilloscope osc_; size_t oscReadPos_{0}; size_t oscSamplesSeen_{0}; diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index 25c794e..42ae706 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -25,6 +25,37 @@ declare class AstraScopeModuleType extends NativeModule { * ~[-1, 1]. Returns the number of points written (0 before warmup). */ getOscilloscopeFrame(out: Float32Array): number; + /** Gate the post-EQ tap (true only while the EQ screen is visible). */ + setActivePostEq(active: boolean): void; + /** + * Like {@link getSpectrumFrame} but for the POST-EQ tap (ring #2) — feeds the + * EQ screen's spectrum behind the response curve. Returns bins written. + */ + getSpectrumFramePostEq(out: Float32Array): number; + + // --- M4 EQ + per-track gain (params pushed from JS; biquad coeffs computed + // natively at the real stream sample rate) --- + + /** Master EQ bypass. When false the EqAudioProcessor is passthrough. */ + setEqEnabled(enabled: boolean): void; + /** EQ preamp as a linear amplitude (1 = unity). */ + setEqPreamp(linear: number): void; + /** + * Flat band params: 5 values per band — [typeOrdinal, frequency, gain, Q, + * enabled?1:0]. Native recomputes biquad coefficients at the stream rate. + */ + setEqBands(params: number[]): void; + /** Set the active per-track normalization/ReplayGain gain (linear; 1 = unity). */ + setNormalizationGain(linear: number): void; + /** + * Register a queued track's gain by URL. The player switches the active gain to the + * matching entry natively at the media-item transition (no JS round-trip). + */ + setTrackGain(url: string, linear: number): void; + /** Activate the registered gain for this URL now (current track on mount/settings). */ + activateTrackGain(url: string): void; + /** Drop all registered per-track gains. */ + clearTrackGains(): void; } export const AstraScope = requireNativeModule('AstraScope'); diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 60b454b..53a88f8 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -1,76 +1,381 @@ -import { View, StyleSheet } from 'react-native'; +import { useCallback, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from 'expo-router'; +import * as DocumentPicker from 'expo-document-picker'; +import { readAsStringAsync } from 'expo-file-system/legacy'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { EQGraph } from '@/components/eq/EQGraph'; +import { BandStrip } from '@/components/eq/BandStrip'; +import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel'; +import { EQSlider } from '@/components/eq/EQSlider'; +import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet'; +import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet'; +import { PresetSheet } from '@/components/eq/PresetSheet'; +import { SavePresetSheet } from '@/components/eq/SavePresetSheet'; import { colors, radius, spacing } from '@/theme'; import { useEQStore } from '@/stores/eqStore'; +import { useScopeActive } from '@/scope/scopeStore'; +import { setActivePostEqNative } from '@/audio/eqNative'; +import { + EQ_MAX_BANDS, + EQ_MAX_FREQUENCY, + EQ_MAX_GAIN_DB, + EQ_MAX_PREAMP_DB, + EQ_MAX_Q, + EQ_MIN_FREQUENCY, + EQ_MIN_PREAMP_DB, + EQ_MIN_Q, + isPassEQBandType, +} from '@/audio/eq'; +import { parseAutoEQ } from '@/audio/autoEQParser'; +import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format'; +import type { EQBand, EQBandType } from '@/types/audio'; -function formatFreq(hz: number): string { - return hz >= 1000 ? `${hz / 1000}k` : `${hz}`; -} +type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type'; + +const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass']; export default function EQScreen() { - const bands = useEQStore((s) => s.bands); + const eq = useEQStore(); + const scopeActive = useScopeActive(); + const [focused, setFocused] = useState(false); + const [sheet, setSheet] = useState('none'); + const [editingValue, setEditingValue] = useState(null); + const closeSheet = useCallback(() => setSheet('none'), []); + + // Gate the post-EQ tap to while this screen is visible. + useFocusEffect( + useCallback(() => { + setFocused(true); + setActivePostEqNative(true); + return () => { + setFocused(false); + setActivePostEqNative(false); + }; + }, []) + ); + + const activeBand = eq.bands.find((b) => b.id === eq.activeBandId) ?? null; + const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1; + const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom'; + const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`; + const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null; + + const handleImportAutoEQ = async () => { + closeSheet(); + try { + const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true }); + if (res.canceled || !res.assets?.[0]) return; + const asset = res.assets[0]; + const content = await readAsStringAsync(asset.uri); + const preset = parseAutoEQ(content, asset.name); + if (preset.bands.length > 0) eq.importPreset(preset); + } catch { + /* invalid file — ignore */ + } + }; return ( - - - Equalizer - - - The band model is in place. The Media3 biquad chain that makes these - sliders live arrives in M4. - - - - {bands.map((band) => ( - - - - - - {formatFreq(band.frequency)} - - - ))} + + + Equalizer + + setSheet('save')} hitSlop={8}> + + + setSheet('overflow')} hitSlop={8}> + + + + + setSheet('preset')}> + + {presetName} + + + + + + eq.updateBand(id, updates)} + /> + + + + eq.addBand()} + /> + + + + 0 ? activeBandNumber : 1} + onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)} + onEditType={() => setSheet('type')} + onEditValue={setEditingValue} + /> + + + + + `${formatGain(v)} dB`} + onChange={eq.setPreamp} + /> + + + + + {eq.enabled ? 'EQ on' : 'EQ off'} + + + + + {sheet === 'preset' ? ( + setSheet('save')} + onClose={closeSheet} + /> + ) : null} + + {sheet === 'save' ? ( + eq.saveCustomPreset(name)} + onClose={closeSheet} + /> + ) : null} + + {sheet === 'overflow' ? ( + + + {eq.bands.length > 1 && activeBand ? ( + { + eq.removeBand(activeBand.id); + closeSheet(); + }} + /> + ) : null} + { + eq.resetToFlat(); + closeSheet(); + }} + /> + + ) : null} + + {sheet === 'type' && activeBand ? ( + + + Filter type + + {BAND_TYPES.map((type) => ( + { + eq.updateBand(activeBand.id, { type }); + closeSheet(); + }} + /> + ))} + + ) : null} + + {valueEditConfig && activeBand && editingValue ? ( + eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))} + onClose={() => setEditingValue(null)} + /> + ) : null} ); } +function getValueEditConfig(kind: EQEditableValue, band: EQBand) { + switch (kind) { + case 'frequency': + return { + title: 'Edit frequency', + initialValue: String(Math.round(band.frequency)), + unit: 'Hz', + rangeLabel: `${EQ_MIN_FREQUENCY}-${EQ_MAX_FREQUENCY} Hz`, + placeholder: '1000 or 1k', + keyboardType: 'default' as const, + parseValue: parseFrequency, + }; + case 'gain': + if (isPassEQBandType(band.type)) return null; + return { + title: 'Edit gain', + initialValue: band.gain.toFixed(1), + unit: 'dB', + rangeLabel: `${-EQ_MAX_GAIN_DB} to +${EQ_MAX_GAIN_DB} dB`, + placeholder: '0.0', + keyboardType: 'numbers-and-punctuation' as const, + parseValue: parseDb, + }; + case 'Q': + return { + title: 'Edit Q', + initialValue: band.Q.toFixed(2), + unit: 'Q', + rangeLabel: `${EQ_MIN_Q}-${EQ_MAX_Q}`, + placeholder: '1.00', + keyboardType: 'numbers-and-punctuation' as const, + parseValue: parsePlainNumber, + }; + } +} + +function createValueUpdate(kind: EQEditableValue, value: number): Partial { + switch (kind) { + case 'frequency': + return { frequency: value }; + case 'gain': + return { gain: value }; + case 'Q': + return { Q: value }; + } +} + +function parseFrequency(value: string): number | null { + const match = value + .trim() + .toLowerCase() + .replace(/,/g, '') + .replace(/\s+/g, '') + .match(/^([+-]?(?:\d+\.?\d*|\.\d+))(khz|hz|k)?$/); + if (!match) return null; + const parsed = Number(match[1]); + if (!Number.isFinite(parsed)) return null; + return match[2] === 'k' || match[2] === 'khz' ? parsed * 1000 : parsed; +} + +function parseDb(value: string): number | null { + const normalized = value.trim().toLowerCase().replace(/\s+/g, ''); + const raw = normalized.endsWith('db') ? normalized.slice(0, -2) : normalized; + return parsePlainNumber(raw); +} + +function parsePlainNumber(value: string): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + const styles = StyleSheet.create({ - heading: { - marginTop: spacing.xl, - }, - note: { - marginTop: spacing.sm, - marginBottom: spacing.xxl, - lineHeight: 20, - }, - bands: { + header: { flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', - }, - band: { - flex: 1, alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm, + }, + headerActions: { + flexDirection: 'row', gap: spacing.sm, }, - track: { - width: 4, - height: 140, - borderRadius: radius.pill, - backgroundColor: colors.glassBorder, + iconButton: { + width: 40, + height: 40, + borderRadius: radius.md, alignItems: 'center', justifyContent: 'center', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, }, - knob: { - width: 14, - height: 14, + presetRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginHorizontal: spacing.lg, + marginBottom: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderRadius: radius.md, + backgroundColor: colors.bgTertiary, + }, + graphWrap: { + flex: 1, + minHeight: 180, + marginHorizontal: spacing.lg, + }, + section: { + marginHorizontal: spacing.lg, + marginTop: spacing.md, + }, + bottomBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm, + }, + preamp: { + flex: 1, + }, + eqToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderRadius: radius.pill, - backgroundColor: colors.accent, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, }, - freq: { - color: colors.textSecondary, + eqToggleOn: { + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, + sheetTitle: { + marginTop: spacing.xs, + marginBottom: spacing.sm, }, }); diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index ef46d8f..a3f84e1 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -1,9 +1,12 @@ -import { View, Pressable, StyleSheet } from 'react-native'; +import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { EQSlider } from '@/components/eq/EQSlider'; import { colors, radius, spacing } from '@/theme'; import { useSettingsStore } from '@/stores/settingsStore'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import type { ReplayGainMode } from '@/audio/normalization'; import type { ArtistGroupingMode } from '@/library/artistGrouping'; const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [ @@ -19,59 +22,159 @@ const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; descri }, ]; +const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [ + { mode: 'auto', label: 'Auto' }, + { mode: 'track', label: 'Track' }, + { mode: 'album', label: 'Album' }, +]; + +function ToggleRow({ + title, + description, + value, + onValueChange, +}: { + title: string; + description: string; + value: boolean; + onValueChange: (v: boolean) => void; +}) { + return ( + + + {title} + + {description} + + + + + ); +} + export default function SettingsScreen() { const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode); + const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled); + const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs); + const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled); + const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode); + const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled); + const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs); + const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled); + const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode); + return ( - - Settings - + + + Settings + - - LIBRARY - - - Artist grouping - - - How tracks are organized into artists in the library. - + + AUDIO + + + void setNormalizationEnabled(v)} + /> + {normalizationEnabled ? ( + + `${Math.round(v)} LUFS`} + onChange={(v) => void setNormalizationTargetLufs(Math.round(v))} + /> + + ) : null} + - - {ARTIST_GROUPING_OPTIONS.map((option) => { - const selected = option.mode === groupingMode; - return ( - void setArtistGroupingMode(option.mode)} - accessibilityRole="radio" - accessibilityState={{ selected }} - > - - - {option.title} - - - {option.description} - - - {selected ? ( - - ) : ( - - )} - - ); - })} - + + void setReplayGainEnabled(v)} + /> + {replayGainEnabled ? ( + + {REPLAYGAIN_MODES.map((m) => { + const selected = m.mode === replayGainMode; + return ( + void setReplayGainMode(m.mode)} + > + + {m.label} + + + ); + })} + + ) : null} + + + + LIBRARY + + + Artist grouping + + + How tracks are organized into artists in the library. + + + + {ARTIST_GROUPING_OPTIONS.map((option) => { + const selected = option.mode === groupingMode; + return ( + void setArtistGroupingMode(option.mode)} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + + + {option.title} + + + {option.description} + + + {selected ? ( + + ) : ( + + )} + + ); + })} + + ); } const styles = StyleSheet.create({ + content: { + paddingBottom: spacing.xxl, + }, heading: { marginTop: spacing.xl, marginBottom: spacing.xxl, @@ -80,6 +183,47 @@ const styles = StyleSheet.create({ letterSpacing: 1, marginBottom: spacing.sm, }, + sectionSpacing: { + marginTop: spacing.xxl, + }, + card: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + }, + cardSpacing: { + marginTop: spacing.sm, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + toggleText: { + flex: 1, + gap: 2, + }, + indent: { + marginTop: spacing.sm, + }, + modeRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.md, + }, + modePill: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + modePillSelected: { + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, settingTitle: { marginBottom: spacing.xs, }, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3713150..51514cc 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -18,6 +18,9 @@ import { import { usePlaybackSync } from '@/audio/usePlaybackSync'; import { useScopeLifecycle } from '@/scope/useScopeLifecycle'; import { useLibraryStore } from '@/stores/libraryStore'; +import { useEQStore } from '@/stores/eqStore'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { useNormalizationSync } from '@/audio/useNormalizationSync'; import { colors } from '@/theme'; SplashScreen.preventAutoHideAsync(); @@ -34,6 +37,12 @@ function ScopeLifecycle() { return null; } +/** Pushes per-track normalization gain to native on track/settings change. */ +function NormalizationSync() { + useNormalizationSync(); + return null; +} + export default function RootLayout() { const [fontsLoaded] = useFonts({ Inter_400Regular, @@ -51,12 +60,21 @@ export default function RootLayout() { }, [fontsLoaded]); // Eager library init: SQLite open + initial reads are tens of ms, and the - // Library tab + playback adapters get data immediately. + // Library tab + playback adapters get data immediately. EQ + audio settings load + // alongside so the native EQ/gain reflect persisted prefs from the first play. useEffect(() => { useLibraryStore .getState() .initialize() .catch((err) => console.error('[library] init failed', err)); + useEQStore + .getState() + .load() + .catch((err) => console.error('[eq] load failed', err)); + useAudioSettingsStore + .getState() + .load() + .catch((err) => console.error('[audioSettings] load failed', err)); }, []); if (!fontsLoaded) return null; @@ -67,6 +85,7 @@ export default function RootLayout() { + s.scopeMode); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); + const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); @@ -194,11 +197,15 @@ export default function NowPlayingScreen() { const source = track?.album?.trim() ? track.album : 'Library'; const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2); const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs; - const artistName = track?.artist.trim() ?? ''; const libraryTrack = useMemo( () => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null), [libraryTracks, track] ); + const artistName = track + ? artistGroupingMode === 'fileTags' + ? resolveStrictBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null }) + : resolveCanonicalBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null }) + : ''; const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key; const navigateToArtist = () => { @@ -454,9 +461,13 @@ export default function NowPlayingScreen() { - + {track.title} - + - + {track.artist} - + - - - - void setScopeStageVisible(!scopeStageVisible)} - accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} - accessibilityState={{ selected: scopeStageVisible }} - > - - - setQueueOpen(true)} - accessibilityLabel="Queue" - > - - + + + + + void setScopeStageVisible(!scopeStageVisible)} + accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} + accessibilityState={{ selected: scopeStageVisible }} + > + + + setQueueOpen(true)} + accessibilityLabel="Queue" + > + + + @@ -778,6 +791,8 @@ const styles = StyleSheet.create({ }, trackTitle: { alignSelf: 'stretch', + }, + trackTitleText: { textAlign: 'left', }, inlineActionBtn: { @@ -789,13 +804,13 @@ const styles = StyleSheet.create({ trackMetaRow: { alignSelf: 'stretch', flexDirection: 'row', - flexWrap: 'wrap', + flexWrap: 'nowrap', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs, }, artistButton: { - flexShrink: 1, + flex: 1, minWidth: 0, }, centered: { @@ -804,9 +819,6 @@ const styles = StyleSheet.create({ artist: { color: colors.accentText, }, - badges: { - flexShrink: 0, - }, spacer: { flex: 1, minHeight: MIN_FLOATING_SPACE, @@ -843,11 +855,22 @@ const styles = StyleSheet.create({ subRow: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'flex-end', - gap: spacing.lg, + justifyContent: 'space-between', + gap: spacing.md, marginTop: SUB_TOP_MARGIN, paddingHorizontal: spacing.sm, }, + subBadges: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + }, + subActions: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 0, + gap: spacing.lg, + }, subBtn: { width: SUB_BUTTON_SIZE, height: SUB_BUTTON_SIZE, diff --git a/src/audio/autoEQParser.ts b/src/audio/autoEQParser.ts new file mode 100644 index 0000000..8991c91 --- /dev/null +++ b/src/audio/autoEQParser.ts @@ -0,0 +1,68 @@ +// AutoEQ ParametricEQ.txt parser — ported from desktop `src/renderer/utils/autoEQParser.ts`. + +import type { EQBand, EQBandType, EQPreset } from '@/types/audio'; +import { EQ_MAX_BANDS, clampEQFrequency, clampEQGain, clampEQQ, clampPreamp } from './eq'; +import { genEqId } from './eqPresets'; + +const TYPE_MAP: Record = { + PK: 'peaking', + LS: 'lowshelf', + HS: 'highshelf', +}; + +/** + * Parse an AutoEQ ParametricEQ.txt file into an EQPreset. + * + * Format: + * Preamp: -6.2 dB + * Filter 1: ON PK Fc 31 Hz Gain 4.5 dB Q 1.41 + * Filter 2: ON LS Fc 105 Hz Gain -2.1 dB Q 0.71 + */ +export function parseAutoEQ(content: string, filename?: string): EQPreset { + const lines = content + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + let preamp = 0; + const bands: EQBand[] = []; + + for (const line of lines) { + const preampMatch = line.match(/^Preamp:\s*([-\d.]+)\s*dB/i); + if (preampMatch) { + preamp = clampPreamp(parseFloat(preampMatch[1])); + continue; + } + + const filterMatch = line.match( + /^Filter\s+\d+:\s*(ON|OFF)\s+(PK|LS|HS)\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB\s+Q\s+([\d.]+)/i + ); + if (filterMatch) { + const [, onOff, typeCode, fc, gain, q] = filterMatch; + if (onOff.toUpperCase() === 'OFF') continue; + + bands.push({ + id: genEqId(), + type: TYPE_MAP[typeCode.toUpperCase()] || 'peaking', + frequency: clampEQFrequency(parseFloat(fc)), + gain: clampEQGain(parseFloat(gain)), + Q: clampEQQ(parseFloat(q)), + enabled: true, + }); + } + } + + bands.sort((a, b) => a.frequency - b.frequency); + + const name = filename + ? filename.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') + : 'Imported AutoEQ'; + + return { + id: genEqId(), + name, + preamp, + bands: bands.slice(0, EQ_MAX_BANDS), + isCustom: true, + }; +} diff --git a/src/audio/eq.ts b/src/audio/eq.ts new file mode 100644 index 0000000..e74c553 --- /dev/null +++ b/src/audio/eq.ts @@ -0,0 +1,266 @@ +// Parametric EQ math + helpers — ported from desktop `src/renderer/utils/eq.ts`. +// The biquad cookbook (Audio EQ Cookbook) magnitude math drives the response curve +// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the +// real stream sample rate — here we only flatten band params for the native bridge. + +import type { EQBand, EQBandType, EQPreset } from '@/types/audio'; + +export const EQ_MIN_GAIN_DB = -12; +export const EQ_MAX_GAIN_DB = 12; +export const EQ_MIN_FREQUENCY = 20; +export const EQ_MAX_FREQUENCY = 20000; +export const EQ_MIN_Q = 0.1; +export const EQ_MAX_Q = 18; +export const EQ_PASS_FILTER_DEFAULT_Q = 0.707; +export const EQ_MAX_BANDS = 10; +export const EQ_MIN_PREAMP_DB = -12; +export const EQ_MAX_PREAMP_DB = 12; +export const EQ_PRESET_VERSION = 1; + +// Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt. +export const EQ_BAND_TYPE_ORDINAL: Record = { + lowshelf: 0, + peaking: 1, + highshelf: 2, + highpass: 3, + lowpass: 4, +}; + +interface RawEQBand { + type?: unknown; + frequency?: unknown; + gain?: unknown; + Q?: unknown; + enabled?: unknown; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function coerceFiniteNumber(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function clampEQGain(value: number): number { + return clamp(value, EQ_MIN_GAIN_DB, EQ_MAX_GAIN_DB); +} + +export function clampEQFrequency(value: number): number { + return clamp(value, EQ_MIN_FREQUENCY, EQ_MAX_FREQUENCY); +} + +export function clampEQQ(value: number): number { + return clamp(value, EQ_MIN_Q, EQ_MAX_Q); +} + +export function clampPreamp(value: number): number { + return clamp(value, EQ_MIN_PREAMP_DB, EQ_MAX_PREAMP_DB); +} + +export function normalizeEQBandType(value: unknown): EQBandType { + switch (value) { + case 'lowshelf': + case 'peaking': + case 'highshelf': + case 'highpass': + case 'lowpass': + return value; + default: + return 'peaking'; + } +} + +export function isPassEQBandType(type: EQBandType): boolean { + return type === 'highpass' || type === 'lowpass'; +} + +/** Pass filters carry no gain — force it to 0. */ +export function normalizeEQBand(band: T): T { + if (!isPassEQBandType(band.type) || band.gain === 0) { + return band; + } + return { ...band, gain: 0 }; +} + +export function createNormalizedEQBand(rawBand: RawEQBand, id: string): EQBand { + const band: EQBand = { + id, + type: normalizeEQBandType(rawBand.type), + frequency: clampEQFrequency(coerceFiniteNumber(rawBand.frequency, 1000)), + gain: clampEQGain(coerceFiniteNumber(rawBand.gain, 0)), + Q: clampEQQ(coerceFiniteNumber(rawBand.Q, 1.0)), + enabled: rawBand.enabled === undefined ? true : rawBand.enabled !== false, + }; + return normalizeEQBand(band); +} + +export function parseEQPresetData(value: unknown, createId: () => string): EQPreset { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid preset file'); + } + const raw = value as { name?: unknown; preamp?: unknown; bands?: unknown }; + if (typeof raw.name !== 'string' || raw.name.trim().length === 0 || !Array.isArray(raw.bands)) { + throw new Error('Invalid preset file'); + } + return { + id: createId(), + name: raw.name.trim(), + preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)), + bands: raw.bands + .slice(0, EQ_MAX_BANDS) + .map((band) => + createNormalizedEQBand( + band && typeof band === 'object' && !Array.isArray(band) ? (band as RawEQBand) : {}, + createId() + ) + ), + isCustom: true, + }; +} + +export function serializeEQPresetData(preset: Pick): { + version: number; + name: string; + preamp: number; + bands: Pick[]; +} { + return { + version: EQ_PRESET_VERSION, + name: preset.name, + preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)), + bands: preset.bands.map((b) => { + const n = normalizeEQBand(b); + return { type: n.type, frequency: n.frequency, gain: n.gain, Q: n.Q, enabled: n.enabled }; + }), + }; +} + +// --------------------------------------------------------------------------- +// Response curve magnitude (Audio EQ Cookbook) — for the Skia response curve. +// --------------------------------------------------------------------------- + +export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number { + if (sampleRate <= 0) return 0; + + const w0 = (2 * Math.PI * band.frequency) / sampleRate; + const w = (2 * Math.PI * testFreq) / sampleRate; + const A = Math.pow(10, band.gain / 40); + const sinW0 = Math.sin(w0); + const cosW0 = Math.cos(w0); + const alpha = sinW0 / (2 * band.Q); + + let b0 = 1; + let b1 = 0; + let b2 = 0; + let a0 = 1; + let a1 = 0; + let a2 = 0; + + switch (band.type) { + case 'peaking': + b0 = 1 + alpha * A; + b1 = -2 * cosW0; + b2 = 1 - alpha * A; + a0 = 1 + alpha / A; + a1 = -2 * cosW0; + a2 = 1 - alpha / A; + break; + case 'lowshelf': { + const sqrtA = Math.sqrt(A); + b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha); + b1 = 2 * A * (A - 1 - (A + 1) * cosW0); + b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha); + a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha; + a1 = -2 * (A - 1 + (A + 1) * cosW0); + a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha; + break; + } + case 'highshelf': { + const sqrtA = Math.sqrt(A); + b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha); + b1 = -2 * A * (A - 1 + (A + 1) * cosW0); + b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha); + a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha; + a1 = 2 * (A - 1 - (A + 1) * cosW0); + a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha; + break; + } + case 'lowpass': + b0 = (1 - cosW0) / 2; + b1 = 1 - cosW0; + b2 = (1 - cosW0) / 2; + a0 = 1 + alpha; + a1 = -2 * cosW0; + a2 = 1 - alpha; + break; + case 'highpass': + b0 = (1 + cosW0) / 2; + b1 = -(1 + cosW0); + b2 = (1 + cosW0) / 2; + a0 = 1 + alpha; + a1 = -2 * cosW0; + a2 = 1 - alpha; + break; + } + + const cosW = Math.cos(w); + const sinW = Math.sin(w); + const cos2W = Math.cos(2 * w); + const sin2W = Math.sin(2 * w); + + const numReal = b0 / a0 + (b1 / a0) * cosW + (b2 / a0) * cos2W; + const numImag = -(b1 / a0) * sinW - (b2 / a0) * sin2W; + const denReal = 1 + (a1 / a0) * cosW + (a2 / a0) * cos2W; + const denImag = -(a1 / a0) * sinW - (a2 / a0) * sin2W; + + const numMag = Math.sqrt(numReal * numReal + numImag * numImag); + const denMag = Math.sqrt(denReal * denReal + denImag * denImag); + + return 20 * Math.log10(numMag / (denMag + 1e-20)); +} + +/** Sum of per-band magnitudes (dB) at a frequency, skipping disabled bands. */ +export function computeCombinedEQMagnitude( + bands: readonly EQBand[], + testFreq: number, + sampleRate: number +): number { + let totalDb = 0; + for (const band of bands) { + if (band.enabled === false) continue; + totalDb += computeEQFilterMagnitude(band, testFreq, sampleRate); + } + return totalDb; +} + +// --------------------------------------------------------------------------- +// Native bridge encoding. +// --------------------------------------------------------------------------- + +/** dB → linear amplitude (for the preamp gain pushed to native). */ +export function dbToLinear(db: number): number { + return Math.pow(10, db / 20); +} + +/** + * Flatten bands into the flat number[] the native EqBridge consumes: + * 5 values per band — [typeOrdinal, frequency, gain, Q, enabled?1:0]. + * Disabled and pass-normalized bands are encoded as-is; Kotlin computes the + * biquad coefficients at the actual stream sample rate. + */ +export function flattenBandsForNative(bands: readonly EQBand[]): number[] { + const out: number[] = []; + for (const band of bands) { + const n = normalizeEQBand(band); + out.push( + EQ_BAND_TYPE_ORDINAL[n.type], + n.frequency, + n.gain, + n.Q, + n.enabled === false ? 0 : 1 + ); + } + return out; +} diff --git a/src/audio/eqNative.ts b/src/audio/eqNative.ts new file mode 100644 index 0000000..3f40384 --- /dev/null +++ b/src/audio/eqNative.ts @@ -0,0 +1,90 @@ +// Thin, defensive wrapper over the native EQ/gain setters on the AstraScope module. +// Guards every call so a JS bundle running against an older native binary (before the +// M4 native rebuild) degrades to a no-op instead of crashing. + +import { AstraScope } from '../../modules/astra-scope'; + +type NativeEq = { + setEqEnabled?: (enabled: boolean) => void; + setEqPreamp?: (linear: number) => void; + setEqBands?: (params: number[]) => void; + setNormalizationGain?: (linear: number) => void; + setTrackGain?: (url: string, linear: number) => void; + activateTrackGain?: (url: string) => void; + clearTrackGains?: () => void; + setActivePostEq?: (active: boolean) => void; +}; + +const native = AstraScope as unknown as NativeEq; + +export function setEqEnabledNative(enabled: boolean): void { + try { + native.setEqEnabled?.(enabled); + } catch { + /* older native binary — no-op */ + } +} + +export function setEqPreampNative(linear: number): void { + try { + native.setEqPreamp?.(linear); + } catch { + /* no-op */ + } +} + +export function setEqBandsNative(params: number[]): void { + try { + native.setEqBands?.(params); + } catch { + /* no-op */ + } +} + +/** Set the active normalization/ReplayGain gain directly (linear). 1 = unity. */ +export function setNormalizationGainNative(linear: number): void { + try { + native.setNormalizationGain?.(linear); + } catch { + /* no-op */ + } +} + +/** + * Register a queued track's gain by URL so the player switches to it natively at the + * media-item transition (no JS round-trip on track change). + */ +export function setTrackGainNative(url: string, linear: number): void { + try { + native.setTrackGain?.(url, linear); + } catch { + /* no-op */ + } +} + +/** Activate the registered gain for this URL now (current track on mount/settings). */ +export function activateTrackGainNative(url: string): void { + try { + native.activateTrackGain?.(url); + } catch { + /* no-op */ + } +} + +/** Drop all registered per-track gains. */ +export function clearTrackGainsNative(): void { + try { + native.clearTrackGains?.(); + } catch { + /* no-op */ + } +} + +/** Gate the post-EQ tap (true only while the EQ screen is visible). */ +export function setActivePostEqNative(active: boolean): void { + try { + native.setActivePostEq?.(active); + } catch { + /* no-op */ + } +} diff --git a/src/audio/eqPresets.ts b/src/audio/eqPresets.ts new file mode 100644 index 0000000..e07f2fa --- /dev/null +++ b/src/audio/eqPresets.ts @@ -0,0 +1,111 @@ +// Default bands + built-in presets — ported from desktop `src/renderer/stores/eqStore.ts`. +// Each band carries the mobile `enabled` flag (default on). + +import type { EQBand, EQPreset } from '@/types/audio'; + +let idCounter = 0; +/** Monotonic, collision-free id for bands/presets (RN-safe, no crypto needed). */ +export function genEqId(): string { + idCounter += 1; + return `eq-${Date.now().toString(36)}-${idCounter.toString(36)}`; +} + +type BandSeed = Omit & { enabled?: boolean }; + +function mkBand(seed: BandSeed): EQBand { + return { + id: genEqId(), + type: seed.type, + frequency: seed.frequency, + gain: seed.gain, + Q: seed.Q, + enabled: seed.enabled ?? true, + }; +} + +// 5-band default (shelves at the extremes, 3 peaking in between). More bands can +// be added from the EQ screen up to EQ_MAX_BANDS. +export const DEFAULT_BAND_SEEDS: BandSeed[] = [ + { type: 'lowshelf', frequency: 60, gain: 0, Q: 0.707 }, + { type: 'peaking', frequency: 250, gain: 0, Q: 1.0 }, + { type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 }, + { type: 'peaking', frequency: 4000, gain: 0, Q: 1.0 }, + { type: 'highshelf', frequency: 12000, gain: 0, Q: 0.707 }, +]; + +/** Fresh default (flat) bands with new ids. */ +export function createDefaultBands(): EQBand[] { + return DEFAULT_BAND_SEEDS.map(mkBand); +} + +interface PresetSeed { + id: string; + name: string; + preamp: number; + bands: BandSeed[]; +} + +const BUILT_IN_SEEDS: PresetSeed[] = [ + { id: 'flat', name: 'Flat', preamp: 0, bands: DEFAULT_BAND_SEEDS }, + { + id: 'bass-boost', + name: 'Bass Boost', + preamp: -2, + bands: [ + { type: 'lowshelf', frequency: 60, gain: 6, Q: 0.707 }, + { type: 'peaking', frequency: 150, gain: 4, Q: 1.0 }, + { type: 'peaking', frequency: 400, gain: 1, Q: 1.0 }, + { type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 }, + { type: 'highshelf', frequency: 12000, gain: 0, Q: 0.707 }, + ], + }, + { + id: 'treble-boost', + name: 'Treble Boost', + preamp: -2, + bands: [ + { type: 'lowshelf', frequency: 60, gain: 0, Q: 0.707 }, + { type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 }, + { type: 'peaking', frequency: 4000, gain: 3, Q: 1.0 }, + { type: 'peaking', frequency: 8000, gain: 5, Q: 1.0 }, + { type: 'highshelf', frequency: 12000, gain: 6, Q: 0.707 }, + ], + }, + { + id: 'vocal', + name: 'Vocal', + preamp: -1, + bands: [ + { type: 'lowshelf', frequency: 80, gain: -2, Q: 0.707 }, + { type: 'peaking', frequency: 250, gain: 1, Q: 1.0 }, + { type: 'peaking', frequency: 1500, gain: 4, Q: 1.2 }, + { type: 'peaking', frequency: 4000, gain: 3, Q: 1.0 }, + { type: 'highshelf', frequency: 12000, gain: 1, Q: 0.707 }, + ], + }, + { + id: 'loudness', + name: 'Loudness', + preamp: -3, + bands: [ + { type: 'lowshelf', frequency: 60, gain: 5, Q: 0.707 }, + { type: 'peaking', frequency: 400, gain: 2, Q: 1.0 }, + { type: 'peaking', frequency: 1000, gain: -1, Q: 1.0 }, + { type: 'peaking', frequency: 4000, gain: 2, Q: 1.0 }, + { type: 'highshelf', frequency: 12000, gain: 5, Q: 0.707 }, + ], + }, +]; + +/** Built-in presets with fresh band ids (call once at store init). */ +export function createBuiltInPresets(): EQPreset[] { + return BUILT_IN_SEEDS.map((seed) => ({ + id: seed.id, + name: seed.name, + preamp: seed.preamp, + bands: seed.bands.map(mkBand), + isCustom: false, + })); +} + +export const FLAT_PRESET_ID = 'flat'; diff --git a/src/audio/normalization.ts b/src/audio/normalization.ts new file mode 100644 index 0000000..358bf07 --- /dev/null +++ b/src/audio/normalization.ts @@ -0,0 +1,133 @@ +// Per-track normalization gain — ported from desktop AudioEngine +// `resolveStaticNormalizationGain` / `resolveGainStateForAnalysis`. +// +// Precedence (desktop-match, locked with the user): +// normalization off -> unity +// ReplayGain on + tag present -> use the tag (clamped) +// else -> targetLufs - scannedLUFS (clamped) +// then back off so peak * linearGain <= 0.98 (peak limiter). + +export const NORM_MIN_GAIN_DB = -18; +export const NORM_MAX_GAIN_DB = 6; +export const NORM_PEAK_CEILING_LINEAR = 0.98; +export const DEFAULT_TARGET_LUFS = -12; + +export type ReplayGainMode = 'auto' | 'track' | 'album'; +export type NormalizationMode = 'off' | 'replaygain' | 'normalization'; + +export interface LoudnessFacts { + loudnessLufs: number | null; + samplePeak: number | null; + replayGainTrackDb: number | null; + replayGainAlbumDb: number | null; + replayGainTrackPeak: number | null; + replayGainAlbumPeak: number | null; +} + +export interface NormalizationSettings { + enabled: boolean; + targetLufs: number; + replayGainEnabled: boolean; + replayGainMode: ReplayGainMode; +} + +export interface ResolvedGain { + gainDb: number; + linearGain: number; + mode: NormalizationMode; + peakLimited: boolean; +} + +const UNITY: ResolvedGain = { gainDb: 0, linearGain: 1, mode: 'off', peakLimited: false }; + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)); +} + +function dbToLinear(db: number): number { + return Math.pow(10, db / 20); +} + +interface PickedReplayGain { + gainDb: number; + /** The peak matching the chosen gain (track gain -> track peak), for clip-limiting. */ + peak: number | null; +} + +/** Pick the ReplayGain gain+peak to use for the given mode, or null if unavailable. */ +function pickReplayGain(facts: LoudnessFacts, mode: ReplayGainMode): PickedReplayGain | null { + const useTrack: PickedReplayGain | null = + facts.replayGainTrackDb != null && Number.isFinite(facts.replayGainTrackDb) + ? { gainDb: facts.replayGainTrackDb, peak: facts.replayGainTrackPeak } + : null; + const useAlbum: PickedReplayGain | null = + facts.replayGainAlbumDb != null && Number.isFinite(facts.replayGainAlbumDb) + ? { gainDb: facts.replayGainAlbumDb, peak: facts.replayGainAlbumPeak } + : null; + switch (mode) { + case 'track': + return useTrack ?? useAlbum; + case 'album': + return useAlbum ?? useTrack; + case 'auto': + default: + // Album gain keeps relative loudness within an album; prefer it when present. + return useAlbum ?? useTrack; + } +} + +/** + * Whether ReplayGain alone can normalize this track (RG on + a usable tag present), + * so callers can skip the expensive loudness decode for tagged libraries. + */ +export function hasUsableReplayGain(facts: LoudnessFacts, settings: NormalizationSettings): boolean { + return settings.replayGainEnabled && pickReplayGain(facts, settings.replayGainMode) != null; +} + +/** Apply the peak ceiling to a candidate gain. */ +function applyPeakLimit( + gainDb: number, + samplePeak: number | null +): { gainDb: number; peakLimited: boolean } { + if (samplePeak == null || samplePeak <= 0) return { gainDb, peakLimited: false }; + const linear = dbToLinear(gainDb); + if (samplePeak * linear <= NORM_PEAK_CEILING_LINEAR) return { gainDb, peakLimited: false }; + const maxLinear = NORM_PEAK_CEILING_LINEAR / samplePeak; + const limitedDb = 20 * Math.log10(maxLinear); + return { gainDb: limitedDb, peakLimited: true }; +} + +export function resolveNormalizationGain( + facts: LoudnessFacts, + settings: NormalizationSettings +): ResolvedGain { + if (!settings.enabled) return UNITY; + + let gainDb: number; + let mode: NormalizationMode; + // Peak used for clip-limiting: the RG tag's own peak in RG mode (falling back to the + // measured sample peak), or the measured peak for loudness normalization. + let peak: number | null; + + const rg = settings.replayGainEnabled ? pickReplayGain(facts, settings.replayGainMode) : null; + if (rg != null) { + gainDb = clamp(rg.gainDb, NORM_MIN_GAIN_DB, NORM_MAX_GAIN_DB); + mode = 'replaygain'; + peak = rg.peak ?? facts.samplePeak; + } else if (facts.loudnessLufs != null && Number.isFinite(facts.loudnessLufs)) { + gainDb = clamp(settings.targetLufs - facts.loudnessLufs, NORM_MIN_GAIN_DB, NORM_MAX_GAIN_DB); + mode = 'normalization'; + peak = facts.samplePeak; + } else { + // Enabled but nothing measured yet — unity until analysis backfills. + return { gainDb: 0, linearGain: 1, mode: 'normalization', peakLimited: false }; + } + + const limited = applyPeakLimit(gainDb, peak); + return { + gainDb: limited.gainDb, + linearGain: dbToLinear(limited.gainDb), + mode, + peakLimited: limited.peakLimited, + }; +} diff --git a/src/audio/trackAnalysis.ts b/src/audio/trackAnalysis.ts new file mode 100644 index 0000000..e764f1b --- /dev/null +++ b/src/audio/trackAnalysis.ts @@ -0,0 +1,100 @@ +// Per-track normalization facts: ReplayGain tags (cheap, container-only) + measured +// integrated LUFS / sample peak (a decode, only when ReplayGain can't cover the track). +// +// ensureTrackLoudness is the single deduped entry point used by the normalization sync +// (current track + queue prefetch). It reads ReplayGain tags once per track, and only +// falls back to the expensive loudness decode when ReplayGain is off or absent — so a +// fully tagged library normalizes with no decoding at all. + +import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; +import type { LibraryDatabase } from '@/db/database'; +import { openLibraryDb } from '@/db/database'; +import { getTrackLoudness, setTrackLoudness, setTrackReplayGain, type TrackLoudness } from '@/db/queries'; +import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; + +function factsFromRow(row: TrackLoudness | null): LoudnessFacts { + return { + loudnessLufs: row?.loudness_lufs ?? null, + samplePeak: row?.sample_peak ?? null, + replayGainTrackDb: row?.replay_gain_track_db ?? null, + replayGainAlbumDb: row?.replay_gain_album_db ?? null, + replayGainTrackPeak: row?.replay_gain_track_peak ?? null, + replayGainAlbumPeak: row?.replay_gain_album_peak ?? null, + }; +} + +/** + * Measure + store integrated loudness + sample peak for one track (always + * re-measures). The decode is the expensive part; failures leave loudness NULL. + */ +export async function measureAndStoreLoudness( + db: LibraryDatabase, + path: string +): Promise<{ lufs: number | null; peak: number | null }> { + try { + const res = await AstraLibraryScanner.measureLoudness(path); + const lufs = res?.lufs ?? null; + const peak = res?.peak ?? null; + await setTrackLoudness(db, path, lufs, peak).catch(() => {}); + return { lufs, peak }; + } catch { + return { lufs: null, peak: null }; + } +} + +const inflight = new Map>(); + +/** + * Loudness facts for a track, reading ReplayGain tags and decoding only as needed + * (deduped by path). Cheap when already analyzed (single DB read). The normalization + * sync uses this so tracks from a pre-M4 library still normalize before a full rescan. + */ +export function ensureTrackLoudness(path: string): Promise { + const existing = inflight.get(path); + if (existing) return existing; + const task = run(path).finally(() => inflight.delete(path)); + inflight.set(path, task); + return task; +} + +async function run(path: string): Promise { + const db = await openLibraryDb(); + const row = await getTrackLoudness(db, path); + let facts = factsFromRow(row); + + // 1. Read ReplayGain tags once per track (container-only, no decode). Decoupled + // from loudness so a track measured before ReplayGain was enabled still picks + // up its tags; rg_scanned stays unset on failure so it retries next touch. + if (!row || row.rg_scanned !== 1) { + try { + const rg = await AstraLibraryScanner.readReplayGain(path); + await setTrackReplayGain(db, path, { + trackGainDb: rg.trackGainDb, + albumGainDb: rg.albumGainDb, + trackPeak: rg.trackPeak, + albumPeak: rg.albumPeak, + }).catch(() => {}); + facts = { + ...facts, + replayGainTrackDb: rg.trackGainDb, + replayGainAlbumDb: rg.albumGainDb, + replayGainTrackPeak: rg.trackPeak, + replayGainAlbumPeak: rg.albumPeak, + }; + } catch { + /* tag read failed — fall through to a loudness measure */ + } + } + + // 2. Loudness already measured — nothing more to do. + if (facts.loudnessLufs != null) return facts; + + // 3. ReplayGain alone can normalize this track — skip the expensive decode. + const settings = useAudioSettingsStore.getState().asNormalizationSettings(); + if (hasUsableReplayGain(facts, settings)) return facts; + + // 4. Otherwise measure loudness now (decode) and merge it in. + const measured = await measureAndStoreLoudness(db, path); + return { ...facts, loudnessLufs: measured.lufs, samplePeak: measured.peak }; +} diff --git a/src/audio/useNormalizationSync.ts b/src/audio/useNormalizationSync.ts new file mode 100644 index 0000000..bd1abcf --- /dev/null +++ b/src/audio/useNormalizationSync.ts @@ -0,0 +1,142 @@ +// Owns per-track normalization gain. It reads each track's loudness facts from +// SQLite, resolves the gain, and registers it natively keyed by URL — for the current +// track AND the next few queued tracks. The player then swaps to the matching gain +// natively at the real media-item transition (no JS round-trip on track change). The +// current track is also activated directly here, since on mount / settings change no +// transition fires. Renders nothing — mount once near the root. + +import { useEffect } from 'react'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useQueueStore } from '@/stores/queueStore'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization'; +import { ensureTrackLoudness } from '@/audio/trackAnalysis'; +import { + setNormalizationGainNative, + setTrackGainNative, + activateTrackGainNative, +} from '@/audio/eqNative'; +import { useScopeStore } from '@/scope/scopeStore'; +import { computeOscilloscopeGain, DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain'; + +const EMPTY_FACTS: LoudnessFacts = { + loudnessLufs: null, + samplePeak: null, + replayGainTrackDb: null, + replayGainAlbumDb: null, + replayGainTrackPeak: null, + replayGainAlbumPeak: null, +}; + +// How many upcoming queue tracks to pre-measure. Bounded work (native decode +// concurrency is capped at 2); covers songs queued a few positions ahead. +const PREFETCH_AHEAD = 5; + +export function useNormalizationSync(): void { + useEffect(() => { + let cancelled = false; + + async function recompute(): Promise { + const path = usePlayerStore.getState().currentTrack?.path ?? null; + const settings = useAudioSettingsStore.getState().asNormalizationSettings(); + if (!path) { + setNormalizationGainNative(1); + useScopeStore.getState().setOscGain(DEFAULT_OSC_GAIN); + return; + } + + // ensureTrackLoudness is cheap when already analyzed (single DB read) and + // decodes+stores on a miss (lazy backfill for pre-scan tracks). + let facts = EMPTY_FACTS; + try { + facts = await ensureTrackLoudness(path); + if (cancelled) return; + // Track changed during the await — let the newer recompute win. + if (usePlayerStore.getState().currentTrack?.path !== path) return; + } catch { + /* fall back to unity via EMPTY_FACTS */ + } + + const resolved = resolveNormalizationGain(facts, settings); + // Seed the native map (so transitioning back to this track picks it up) and make + // it active now (mount / settings change fire no media-item transition). + setTrackGainNative(path, resolved.linearGain); + activateTrackGainNative(path); + + // Pick the oscilloscope's per-track display gain from the track's peak and the + // gain we just applied (the scope tap is post-normalization). Held constant for + // the whole track, so dynamics within the song are preserved. + const basePeak = + facts.samplePeak ?? facts.replayGainTrackPeak ?? facts.replayGainAlbumPeak ?? null; + useScopeStore.getState().setOscGain(computeOscilloscopeGain(basePeak, resolved.linearGain)); + } + + // Warm the next several upcoming tracks' loudness while the current one plays, and + // register each one's resolved gain natively by URL — so when the player advances, + // the gain is already in the map and gets applied at the transition with no JS in + // the loop. Looking a few ahead (not just the immediate next) means a song added + // several positions back is still measured + registered with plenty of lead time. + // Derived from the queue mirror, so it re-runs on reorder / add-next / advance. + // Deduped + DB-cached + native-semaphore-capped, so it stays cheap and gentle. + function prefetchUpcoming(): void { + const { tracks, activeIndex } = useQueueStore.getState(); + if (activeIndex < 0) return; + const settings = useAudioSettingsStore.getState().asNormalizationSettings(); + for (let i = 1; i <= PREFETCH_AHEAD; i++) { + const url = tracks[activeIndex + i]?.url; + if (typeof url !== 'string' || url.length === 0) continue; + void ensureTrackLoudness(url) + .then((facts) => { + if (cancelled) return; + const resolved = resolveNormalizationGain(facts, settings); + setTrackGainNative(url, resolved.linearGain); + }) + .catch(() => { + /* leave unregistered — defaults to unity at the transition */ + }); + } + } + + // The queue can change rapidly (drag-reorder); coalesce re-warms. + let prefetchTimer: ReturnType | null = null; + function schedulePrefetch(): void { + if (prefetchTimer) clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(() => { + prefetchTimer = null; + prefetchUpcoming(); + }, 250); + } + + const unsubTrack = usePlayerStore.subscribe((state, prev) => { + if (state.currentTrack?.path !== prev.currentTrack?.path) void recompute(); + }); + const unsubQueue = useQueueStore.subscribe((state, prev) => { + // Re-warm when the upcoming order changes (reorder, add-next, remove, advance). + if (state.tracks !== prev.tracks || state.activeIndex !== prev.activeIndex) { + schedulePrefetch(); + } + }); + const unsubSettings = useAudioSettingsStore.subscribe((state, prev) => { + if ( + state.normalizationEnabled !== prev.normalizationEnabled || + state.normalizationTargetLufs !== prev.normalizationTargetLufs || + state.replayGainEnabled !== prev.replayGainEnabled || + state.replayGainMode !== prev.replayGainMode + ) { + void recompute(); + // Upcoming tracks' gains depend on the same settings — re-register them. + schedulePrefetch(); + } + }); + void recompute(); + prefetchUpcoming(); + + return () => { + cancelled = true; + if (prefetchTimer) clearTimeout(prefetchTimer); + unsubTrack(); + unsubQueue(); + unsubSettings(); + }; + }, []); +} diff --git a/src/components/FormatBadge.tsx b/src/components/FormatBadge.tsx index 192536a..ab611e6 100644 --- a/src/components/FormatBadge.tsx +++ b/src/components/FormatBadge.tsx @@ -20,8 +20,10 @@ export function Badge({ label }: { label: string }) { */ export function FormatBadges({ track, + wrap = true, }: { track: Pick; + wrap?: boolean; }) { const labels: string[] = []; if (track.format) labels.push(track.format.toUpperCase()); @@ -31,7 +33,7 @@ export function FormatBadges({ if (labels.length === 0) return null; return ( - + {labels.map((label) => ( ))} @@ -45,6 +47,9 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: spacing.xs, }, + rowNoWrap: { + flexWrap: 'nowrap', + }, badge: { backgroundColor: colors.glassBg, borderColor: colors.glassBorder, diff --git a/src/components/MarqueeText.tsx b/src/components/MarqueeText.tsx new file mode 100644 index 0000000..a146f2c --- /dev/null +++ b/src/components/MarqueeText.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { + StyleSheet, + View, + type LayoutChangeEvent, + type StyleProp, + type TextStyle, + type ViewStyle, +} from 'react-native'; +import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes'; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { Text } from './Text'; + +type TextVariant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono'; + +const DEFAULT_DELAY_MS = 900; +const DEFAULT_HOLD_MS = 900; +const DEFAULT_SPEED_PX_PER_SECOND = 28; +const MIN_DURATION_MS = 1600; +const MEASURE_WIDTH = 10000; + +interface MarqueeTextProps { + children: string; + variant?: TextVariant; + color?: string; + style?: StyleProp; + containerStyle?: StyleProp; + delayMs?: number; + holdMs?: number; + speedPxPerSecond?: number; +} + +export function MarqueeText({ + children, + variant = 'body', + color, + style, + containerStyle, + delayMs = DEFAULT_DELAY_MS, + holdMs = DEFAULT_HOLD_MS, + speedPxPerSecond = DEFAULT_SPEED_PX_PER_SECOND, +}: MarqueeTextProps) { + const [containerWidth, setContainerWidth] = useState(0); + const [textWidth, setTextWidth] = useState(0); + const offset = useSharedValue(0); + const overflowDistance = Math.max(0, Math.ceil(textWidth - containerWidth)); + + useEffect(() => { + cancelAnimation(offset); + offset.value = 0; + + if (overflowDistance <= 1) return; + + const duration = Math.max( + MIN_DURATION_MS, + Math.round((overflowDistance / speedPxPerSecond) * 1000) + ); + offset.value = withDelay( + delayMs, + withRepeat( + withSequence( + withTiming(-overflowDistance, { duration, easing: Easing.linear }), + withDelay(holdMs, withTiming(-overflowDistance, { duration: 0 })), + withTiming(0, { duration, easing: Easing.linear }), + withDelay(holdMs, withTiming(0, { duration: 0 })) + ), + -1, + false + ) + ); + }, [delayMs, holdMs, offset, overflowDistance, speedPxPerSecond]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], + })); + + const handleContainerLayout = (event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width); + }; + + const handleTextLayout = (event: TextLayoutEvent) => { + const measuredWidth = Math.ceil(event.nativeEvent.lines[0]?.width ?? 0); + setTextWidth((current) => (Math.abs(current - measuredWidth) > 1 ? measuredWidth : current)); + }; + + return ( + + 0 ? { width: textWidth } : null, animatedStyle]} + > + + {children} + + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + content: { + alignSelf: 'flex-start', + }, + measure: { + position: 'absolute', + width: MEASURE_WIDTH, + opacity: 0, + }, +}); + +export default MarqueeText; diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index f29242c..6f60686 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -8,6 +8,8 @@ import { type SkPicture, } from '@shopify/react-native-skia'; import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope'; +import { useScopeStore } from '@/scope/scopeStore'; +import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain'; import { colors } from '@/theme'; interface OscilloscopeWaveProps { @@ -25,7 +27,6 @@ type SkiaViewApiShape = { requestRedraw: (nativeId: number) => void; }; -const VISUAL_GAIN = 1.8; const values = new Float32Array(OSCILLOSCOPE_POINTS); function skiaViewApi(): SkiaViewApiShape | null { @@ -58,7 +59,8 @@ function buildPicture( height: number, color: string, lineWidth: number, - glow: boolean + glow: boolean, + gain: number ): SkPicture { const recorder = Skia.PictureRecorder(); const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height)); @@ -70,7 +72,9 @@ function buildPicture( const amp = mid - lineWidth; const xAt = (i: number) => (i / (n - 1)) * width; const yAt = (i: number) => { - let v = samples[i] * VISUAL_GAIN; + let v = samples[i] * gain; + // Per-track gain targets ~85% of full scale, so this only catches the rare + // intra-track peak that runs a touch hotter than the analyzed sample peak. if (v < -1) v = -1; else if (v > 1) v = 1; return mid - v * amp; @@ -94,6 +98,10 @@ function buildPicture( * Imperative oscilloscope renderer. This mirrors desktop/prism's hot path: * a frame loop pulls native scope data and draws directly into a canvas-like * surface instead of routing each frame through React reconciliation. + * + * Amplitude uses a per-track display gain (scopeStore.oscGain, set once per track by + * useNormalizationSync) — read fresh each frame so it tracks song changes, but held + * constant within a track so the music's own dynamics are preserved. */ export function OscilloscopeWave({ active, @@ -106,7 +114,17 @@ export function OscilloscopeWave({ }: OscilloscopeWaveProps) { const viewRef = useRef(null); const initialPicture = useMemo( - () => buildPicture(values, values.length, Math.max(1, width), Math.max(1, height), color, lineWidth, glow), + () => + buildPicture( + values, + values.length, + Math.max(1, width), + Math.max(1, height), + color, + lineWidth, + glow, + DEFAULT_OSC_GAIN + ), [color, glow, height, lineWidth, width] ); @@ -119,7 +137,8 @@ export function OscilloscopeWave({ let raf = 0; const draw = (sampleCount: number) => { - const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow); + const gain = useScopeStore.getState().oscGain; + const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow, gain); api.setJsiProperty(view.nativeId, 'picture', picture); api.requestRedraw(view.nativeId); }; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 1073696..9e7fbc2 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -18,6 +18,8 @@ interface SpectrumCurveProps { height: number; /** Pull native spectrum frames while active, bypassing React per-frame state. */ active?: boolean; + /** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */ + source?: 'pre' | 'post'; /** Number of render points when active. Defaults to one point per rendered pixel. */ pointCount?: number; /** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */ @@ -292,6 +294,7 @@ export function SpectrumCurve({ width, height, active = false, + source = 'pre', pointCount, frameMs = MINI_FRAME_MS, analysisFrameMs, @@ -391,7 +394,11 @@ export function SpectrumCurve({ raf = requestAnimationFrame(tick); if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) { lastAnalysis = t; - if (AstraScope.getSpectrumFrame(spectrumBins) > 0) { + const got = + source === 'post' + ? AstraScope.getSpectrumFramePostEq(spectrumBins) + : AstraScope.getSpectrumFrame(spectrumBins); + if (got > 0) { writeSpectrumPoints(spectrumBins, renderValues, pointOptions); hasNewFrame = true; } @@ -425,6 +432,7 @@ export function SpectrumCurve({ lineOpacity, lineWidth, resolvedPointCount, + source, tiltDbPerOctave, width, ]); diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx new file mode 100644 index 0000000..d9c774f --- /dev/null +++ b/src/components/eq/BandDetailPanel.tsx @@ -0,0 +1,125 @@ +import { Pressable, StyleSheet, Switch, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import type { EQBand } from '@/types/audio'; +import { EQ_MAX_FREQUENCY, EQ_MAX_GAIN_DB, EQ_MAX_Q, EQ_MIN_FREQUENCY, EQ_MIN_Q, isPassEQBandType } from '@/audio/eq'; +import { EQSlider } from './EQSlider'; +import { BAND_TYPE_LABEL, formatFreq, formatGain } from './format'; + +interface BandDetailPanelProps { + band: EQBand | null; + bandNumber: number; + onUpdate: (updates: Partial) => void; + /** Open the filter-type picker (the sheet lives at the screen root). */ + onEditType: () => void; + /** Open the exact value editor (the sheet lives at the screen root). */ + onEditValue: (value: EQEditableValue) => void; +} + +export type EQEditableValue = 'frequency' | 'gain' | 'Q'; + +/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */ +export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) { + if (!band) { + return ( + + + Select a band to edit. + + + ); + } + + const isPass = isPassEQBandType(band.type); + + return ( + + + Band {bandNumber} + + + {BAND_TYPE_LABEL[band.type]} + + + + + {band.enabled ? 'On' : 'Off'} + onUpdate({ enabled })} + trackColor={{ false: colors.glassBorder, true: colors.accent }} + thumbColor={colors.textPrimary} + /> + + + + `${formatFreq(v)} Hz`} + onChange={(v) => onUpdate({ frequency: v })} + onValuePress={() => onEditValue('frequency')} + /> + `${formatGain(v)} dB`} + onChange={(v) => onUpdate({ gain: v })} + onValuePress={() => onEditValue('gain')} + disabled={isPass} + /> + v.toFixed(2)} + onChange={(v) => onUpdate({ Q: v })} + onValuePress={() => onEditValue('Q')} + /> + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + gap: spacing.xs, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginBottom: spacing.sm, + }, + typeButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + toggle: { + marginLeft: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, +}); + +export default BandDetailPanel; diff --git a/src/components/eq/BandStrip.tsx b/src/components/eq/BandStrip.tsx new file mode 100644 index 0000000..48d6444 --- /dev/null +++ b/src/components/eq/BandStrip.tsx @@ -0,0 +1,87 @@ +import { Pressable, ScrollView, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import type { EQBand } from '@/types/audio'; +import { formatFreq, formatGain, gainColor } from './format'; + +interface BandStripProps { + bands: EQBand[]; + activeBandId: string | null; + canAdd: boolean; + onSelect: (id: string) => void; + onAdd: () => void; +} + +/** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */ +export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) { + return ( + + {bands.map((band) => { + const isActive = band.id === activeBandId; + return ( + onSelect(band.id)} + style={[styles.cell, isActive && styles.cellActive]} + > + + {formatFreq(band.frequency)} + + + {formatGain(band.gain)} + + + ); + })} + {canAdd ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + content: { + gap: spacing.sm, + paddingVertical: spacing.xs, + }, + cell: { + minWidth: 66, + alignItems: 'center', + gap: 2, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + backgroundColor: colors.glassBg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'transparent', + }, + cellActive: { + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + addCell: { + justifyContent: 'center', + borderColor: colors.glassBorder, + borderStyle: 'dashed', + minWidth: 52, + }, + freq: { + color: colors.textSecondary, + }, + gain: { + fontSize: 15, + }, +}); + +export default BandStrip; diff --git a/src/components/eq/EQGraph.tsx b/src/components/eq/EQGraph.tsx new file mode 100644 index 0000000..8bbcae0 --- /dev/null +++ b/src/components/eq/EQGraph.tsx @@ -0,0 +1,330 @@ +import { useMemo, useRef, useState } from 'react'; +import { + View, + StyleSheet, + type GestureResponderEvent, + type LayoutChangeEvent, +} from 'react-native'; +import { + Canvas, + Circle, + DashPathEffect, + Group, + Path, + Skia, + type SkPath, +} from '@shopify/react-native-skia'; +import { Text } from '@/components/Text'; +import { SpectrumCurve } from '@/components/SpectrumCurve'; +import { colors } from '@/theme'; +import type { EQBand } from '@/types/audio'; +import { + FREQ_TICKS, + buildResponseFill, + buildResponsePath, + freqToX, + gainToY, + xToFreq, + yToGain, +} from './eqGraphMath'; + +const HIT_RADIUS = 34; +const NODE_R = 13; + +interface EQGraphProps { + bands: EQBand[]; + activeBandId: string | null; + enabled: boolean; + /** Pull the live post-EQ spectrum behind the curve. */ + spectrumActive: boolean; + onSelectBand: (id: string) => void; + onChangeBand: (id: string, updates: { frequency: number; gain: number }) => void; +} + +/** + * The EQ response graph: a live post-EQ spectrum behind a draggable response curve + * with one numbered node per band. Skia draws the curve/grid/nodes; a transparent + * RN responder maps touches to the nearest node and drags it (x → frequency, y → + * gain). Q is edited from the detail panel, not the curve. + */ +export function EQGraph({ + bands, + activeBandId, + enabled, + spectrumActive, + onSelectBand, + onChangeBand, +}: EQGraphProps) { + const [size, setSize] = useStableSize(); + const width = size.width; + const height = size.height; + + // Anchor the grabbed node + grant page coords; move by absolute page deltas + // (clamped to the graph) so veering off-bounds can't snap to a corner. + const dragRef = useRef<{ + id: string; + pageX: number; + pageY: number; + startX: number; + startY: number; + } | null>(null); + + const linePath = useMemo( + () => buildResponsePath(bands, width, height), + [bands, width, height] + ); + const fillPath = useMemo( + () => buildResponseFill(linePath, width, height), + [linePath, width, height] + ); + + const onLayout = (e: LayoutChangeEvent) => { + setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height }); + }; + + const nearestBandId = (x: number, y: number): string | null => { + let best: string | null = null; + let bestDist = HIT_RADIUS * HIT_RADIUS; + for (const band of bands) { + const bx = freqToX(band.frequency, width); + const by = gainToY(band.gain, height); + const d = (bx - x) ** 2 + (by - y) ** 2; + if (d <= bestDist) { + bestDist = d; + best = band.id; + } + } + return best; + }; + + const handleGrant = (e: GestureResponderEvent) => { + const { locationX, locationY, pageX, pageY } = e.nativeEvent; + const id = nearestBandId(locationX, locationY); + if (!id) { + dragRef.current = null; + return; + } + const band = bands.find((b) => b.id === id); + if (!band) return; + dragRef.current = { + id, + pageX, + pageY, + startX: freqToX(band.frequency, width), + startY: gainToY(band.gain, height), + }; + onSelectBand(id); + }; + + const handleMove = (e: GestureResponderEvent) => { + const d = dragRef.current; + if (!d) return; + const { pageX, pageY } = e.nativeEvent; + const nx = Math.max(0, Math.min(width, d.startX + (pageX - d.pageX))); + const ny = Math.max(0, Math.min(height, d.startY + (pageY - d.pageY))); + onChangeBand(d.id, { frequency: xToFreq(nx, width), gain: yToGain(ny, height) }); + }; + + const endDrag = () => { + dragRef.current = null; + }; + + const curveColor = enabled ? colors.accent : colors.textTertiary; + const centerY = height / 2; + const yPlus6 = gainToY(6, height); + const yMinus6 = gainToY(-6, height); + + return ( + + {width > 0 && height > 0 ? ( + <> + {/* Live post-EQ spectrum behind the curve. */} + + + + + + {/* Grid: ±6 dB lines + dashed 0 dB centerline. */} + + + + + + + + + {/* Response curve + soft fill. */} + + + + {/* Band nodes. */} + {bands.map((band) => { + const cx = freqToX(band.frequency, width); + const cy = gainToY(band.gain, height); + const isActive = band.id === activeBandId; + const dim = !band.enabled || !enabled; + return ( + + + + + ); + })} + + + {/* Node numbers (RN text over the canvas). */} + {bands.map((band, i) => { + const cx = freqToX(band.frequency, width); + const cy = gainToY(band.gain, height); + const isActive = band.id === activeBandId; + return ( + + {i + 1} + + ); + })} + + {/* dB labels (right edge). */} + + +6 + + + -6 + + + {/* Frequency labels (bottom axis). */} + {FREQ_TICKS.map((tick) => ( + + {tick.label} + + ))} + + {/* Gesture overlay. */} + true} + onMoveShouldSetResponder={() => true} + onResponderTerminationRequest={() => false} + onResponderGrant={handleGrant} + onResponderMove={handleMove} + onResponderRelease={endDrag} + onResponderTerminate={endDrag} + /> + + ) : null} + + ); +} + +// --- helpers --------------------------------------------------------------- + +function hLine(x0: number, y: number, width: number): SkPath { + const p = Skia.Path.Make(); + p.moveTo(x0, y); + p.lineTo(width, y); + return p; +} + +function withAlpha(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function useStableSize(): [ + { width: number; height: number }, + (s: { width: number; height: number }) => void, +] { + const [size, setSize] = useState({ width: 0, height: 0 }); + const set = (s: { width: number; height: number }) => { + setSize((prev) => (prev.width === s.width && prev.height === s.height ? prev : s)); + }; + return [size, set]; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: colors.bgSecondary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + nodeLabel: { + position: 'absolute', + width: NODE_R * 2, + textAlign: 'center', + fontSize: 12, + }, + dbLabel: { + position: 'absolute', + right: 8, + color: colors.textTertiary, + }, + freqLabel: { + position: 'absolute', + bottom: 4, + width: 20, + textAlign: 'center', + color: colors.textTertiary, + }, +}); + +export default EQGraph; diff --git a/src/components/eq/EQSlider.tsx b/src/components/eq/EQSlider.tsx new file mode 100644 index 0000000..fe52cf1 --- /dev/null +++ b/src/components/eq/EQSlider.tsx @@ -0,0 +1,196 @@ +import { useRef, useState } from 'react'; +import { + Pressable, + View, + StyleSheet, + type GestureResponderEvent, + type LayoutChangeEvent, +} from 'react-native'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; + +const THUMB = 16; + +interface EQSliderProps { + label: string; + value: number; + min: number; + max: number; + /** Logarithmic mapping (for frequency). */ + log?: boolean; + format: (v: number) => string; + onChange: (v: number) => void; + disabled?: boolean; + onValuePress?: () => void; +} + +const clamp01 = (f: number) => Math.min(1, Math.max(0, f)); + +/** Labeled horizontal slider following the SeekBar gesture/derivation pattern. */ +export function EQSlider({ + label, + value, + min, + max, + log, + format, + onChange, + disabled, + onValuePress, +}: EQSliderProps) { + const [width, setWidth] = useState(0); + const [active, setActive] = useState(false); + const widthRef = useRef(0); + // Anchor on grant, then track absolute pageX deltas so veering off the row + // vertically can't corrupt the value (the SeekBar pattern). + const grantRef = useRef({ fraction: 0, pageX: 0 }); + + const valueToFraction = (v: number): number => { + if (log) { + const lo = Math.log10(min); + const hi = Math.log10(max); + return clamp01((Math.log10(Math.max(min, v)) - lo) / (hi - lo)); + } + return clamp01((v - min) / (max - min)); + }; + + const fractionToValue = (f: number): number => { + if (log) { + const lo = Math.log10(min); + const hi = Math.log10(max); + return 10 ** (lo + clamp01(f) * (hi - lo)); + } + return min + clamp01(f) * (max - min); + }; + + const onLayout = (e: LayoutChangeEvent) => { + widthRef.current = e.nativeEvent.layout.width; + setWidth(e.nativeEvent.layout.width); + }; + + const handleGrant = (e: GestureResponderEvent) => { + setActive(true); + const f = clamp01(e.nativeEvent.locationX / Math.max(1, widthRef.current)); + grantRef.current = { fraction: f, pageX: e.nativeEvent.pageX }; + onChange(fractionToValue(f)); + }; + + const handleMove = (e: GestureResponderEvent) => { + const delta = (e.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current); + onChange(fractionToValue(clamp01(grantRef.current.fraction + delta))); + }; + + const fraction = valueToFraction(value); + + return ( + + + {label} + + !disabled} + onMoveShouldSetResponder={() => !disabled} + onResponderTerminationRequest={() => false} + onResponderGrant={handleGrant} + onResponderMove={handleMove} + onResponderRelease={() => setActive(false)} + onResponderTerminate={() => setActive(false)} + accessibilityRole="adjustable" + accessibilityLabel={label} + > + + + + + + {onValuePress && !disabled ? ( + [styles.valueButton, pressed && styles.valueButtonPressed]} + onPress={onValuePress} + accessibilityRole="button" + accessibilityLabel={`Edit ${label}`} + > + + {format(value)} + + + ) : ( + + {format(value)} + + )} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.xs, + }, + disabled: { + opacity: 0.4, + }, + label: { + width: 78, + }, + touch: { + flex: 1, + justifyContent: 'center', + paddingVertical: spacing.sm, + }, + track: { + height: 4, + borderRadius: radius.pill, + backgroundColor: colors.glassBorder, + overflow: 'hidden', + }, + fill: { + height: 4, + borderRadius: radius.pill, + backgroundColor: colors.accent, + }, + thumb: { + position: 'absolute', + width: THUMB, + height: THUMB, + borderRadius: THUMB / 2, + backgroundColor: colors.accent, + }, + thumbActive: { + transform: [{ scale: 1.3 }], + backgroundColor: colors.accentHover, + }, + valueButton: { + minWidth: 68, + alignItems: 'flex-end', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + valueButtonPressed: { + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + value: { + width: 64, + textAlign: 'right', + color: colors.textPrimary, + }, +}); + +export default EQSlider; diff --git a/src/components/eq/EQValueEditSheet.tsx b/src/components/eq/EQValueEditSheet.tsx new file mode 100644 index 0000000..76d4e53 --- /dev/null +++ b/src/components/eq/EQValueEditSheet.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { + Pressable, + StyleSheet, + View, + type KeyboardTypeOptions, +} from 'react-native'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { Text } from '@/components/Text'; +import { colors, fonts, radius, spacing } from '@/theme'; +import { EqSheet } from './EqSheet'; + +interface EQValueEditSheetProps { + title: string; + initialValue: string; + unit: string; + rangeLabel: string; + placeholder?: string; + keyboardType?: KeyboardTypeOptions; + parseValue: (value: string) => number | null; + onApply: (value: number) => void; + onClose: () => void; +} + +/** Focused numeric editor for exact EQ band values. */ +export function EQValueEditSheet({ + title, + initialValue, + unit, + rangeLabel, + placeholder, + keyboardType = 'numbers-and-punctuation', + parseValue, + onApply, + onClose, +}: EQValueEditSheetProps) { + const [value, setValue] = useState(initialValue); + const trimmed = value.trim(); + const parsed = trimmed.length > 0 ? parseValue(trimmed) : null; + const valid = parsed !== null; + + const apply = () => { + if (parsed === null) return; + onApply(parsed); + onClose(); + }; + + return ( + + + {title} + + + 0 && !valid && styles.inputInvalid]} + autoFocus + selectTextOnFocus + maxLength={16} + returnKeyType="done" + onSubmitEditing={apply} + selectionColor={colors.accent} + /> + + {unit} + + + 0 && !valid && styles.invalidText]}> + {valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'} + + + + + Cancel + + + + + Apply + + + + + ); +} + +const styles = StyleSheet.create({ + title: { + marginTop: spacing.xs, + marginBottom: spacing.md, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + input: { + flex: 1, + color: colors.textPrimary, + fontFamily: fonts.mono.regular, + fontSize: 18, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + inputInvalid: { + borderColor: colors.warning, + }, + unit: { + minWidth: 34, + color: colors.textSecondary, + }, + range: { + marginTop: spacing.sm, + color: colors.textTertiary, + }, + invalidText: { + color: colors.warning, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, + btn: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + borderRadius: radius.pill, + }, + cancel: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + apply: { + backgroundColor: colors.accentGlow, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + }, + applyDisabled: { + opacity: 0.4, + }, +}); + +export default EQValueEditSheet; diff --git a/src/components/eq/EqSheet.tsx b/src/components/eq/EqSheet.tsx new file mode 100644 index 0000000..a60aa7a --- /dev/null +++ b/src/components/eq/EqSheet.tsx @@ -0,0 +1,132 @@ +import { useCallback, type ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetView, + type BottomSheetBackdropProps, +} from '@gorhom/bottom-sheet'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; + +/** + * Bottom sheet for the EQ screen's menus — same chrome/behaviour as the now-playing + * QueueTray (inline gorhom BottomSheet, dimmed backdrop, grab handle, pan-to-close) + * so trays stay consistent across the app. Dynamically sized to its content; render + * it conditionally ({open && }). + */ +export function EqSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) { + const insets = useSafeAreaInsets(); + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + return ( + + + {children} + + + ); +} + +/** Section label inside a sheet. */ +export function EqSheetSection({ label }: { label: string }) { + return ( + + {label} + + ); +} + +interface EqSheetItemProps { + label: string; + icon?: keyof typeof Ionicons.glyphMap; + selected?: boolean; + destructive?: boolean; + onPress: () => void; + /** Optional trailing control (e.g. a delete button). */ + trailing?: ReactNode; +} + +/** One tappable row, styled like the ActionSheet items it replaces. */ +export function EqSheetItem({ label, icon, selected, destructive, onPress, trailing }: EqSheetItemProps) { + const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary; + return ( + + [styles.item, pressed && styles.itemPressed]} + onPress={onPress} + accessibilityRole="button" + > + {icon ? ( + + ) : null} + + {label} + + {selected ? : null} + + {trailing} + + ); +} + +const styles = StyleSheet.create({ + sheetBg: { + backgroundColor: colors.bgSecondary, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + }, + handle: { + backgroundColor: colors.glassBorder, + width: 38, + }, + content: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.xs, + }, + section: { + color: colors.textTertiary, + letterSpacing: 1, + marginTop: spacing.md, + marginBottom: spacing.xs, + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + }, + item: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + }, + itemPressed: { + opacity: 0.6, + }, + itemLabel: { + flex: 1, + }, +}); + +export default EqSheet; diff --git a/src/components/eq/PresetSheet.tsx b/src/components/eq/PresetSheet.tsx new file mode 100644 index 0000000..9198682 --- /dev/null +++ b/src/components/eq/PresetSheet.tsx @@ -0,0 +1,102 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { colors, spacing } from '@/theme'; +import type { EQPreset } from '@/types/audio'; +import { EqSheet, EqSheetItem, EqSheetSection } from './EqSheet'; + +interface PresetSheetProps { + presets: EQPreset[]; + activePresetId: string | null; + onApply: (id: string) => void; + onDelete: (id: string) => void; + onSaveNew: () => void; + onClose: () => void; +} + +/** Preset hub: pick a built-in or custom preset, delete custom ones, or save a new one. */ +export function PresetSheet({ + presets, + activePresetId, + onApply, + onDelete, + onSaveNew, + onClose, +}: PresetSheetProps) { + const builtIn = presets.filter((p) => !p.isCustom); + const custom = presets.filter((p) => p.isCustom); + + return ( + + + Presets + + + + {builtIn.map((p) => ( + { + onApply(p.id); + onClose(); + }} + /> + ))} + + + {custom.length === 0 ? ( + + No saved presets yet. + + ) : ( + custom.map((p) => ( + { + onApply(p.id); + onClose(); + }} + trailing={ + onDelete(p.id)} + style={styles.delete} + accessibilityLabel={`Delete preset ${p.name}`} + > + + + } + /> + )) + )} + + { + onClose(); + onSaveNew(); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + title: { + marginTop: spacing.xs, + }, + empty: { + paddingVertical: spacing.sm, + }, + delete: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, +}); + +export default PresetSheet; diff --git a/src/components/eq/SavePresetSheet.tsx b/src/components/eq/SavePresetSheet.tsx new file mode 100644 index 0000000..a5c7408 --- /dev/null +++ b/src/components/eq/SavePresetSheet.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { Text } from '@/components/Text'; +import { colors, fonts, radius, spacing } from '@/theme'; +import { EqSheet } from './EqSheet'; + +interface SavePresetSheetProps { + defaultName: string; + onSave: (name: string) => void; + onClose: () => void; +} + +/** Name + save a custom preset from the current bands/preamp. */ +export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) { + const [name, setName] = useState(defaultName); + const trimmed = name.trim(); + + return ( + + + Save preset + + { + if (trimmed) { + onSave(trimmed); + onClose(); + } + }} + /> + + + + Cancel + + + { + onSave(trimmed); + onClose(); + }} + > + + Save + + + + + ); +} + +const styles = StyleSheet.create({ + title: { + marginTop: spacing.xs, + marginBottom: spacing.md, + }, + input: { + color: colors.textPrimary, + fontFamily: fonts.sans.regular, + fontSize: 16, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, + btn: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + borderRadius: radius.pill, + }, + cancel: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + save: { + backgroundColor: colors.accentGlow, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + }, + saveDisabled: { + opacity: 0.4, + }, +}); + +export default SavePresetSheet; diff --git a/src/components/eq/eqGraphMath.ts b/src/components/eq/eqGraphMath.ts new file mode 100644 index 0000000..dd2593a --- /dev/null +++ b/src/components/eq/eqGraphMath.ts @@ -0,0 +1,83 @@ +// Coordinate mapping + response-curve sampling for the EQ graph. Frequency is on a +// log axis (20 Hz–20 kHz); gain is linear (±12 dB) centered vertically. + +import { Skia, type SkPath } from '@shopify/react-native-skia'; +import type { EQBand } from '@/types/audio'; +import { + EQ_MAX_FREQUENCY, + EQ_MAX_GAIN_DB, + EQ_MIN_FREQUENCY, + computeCombinedEQMagnitude, +} from '@/audio/eq'; + +export const GRAPH_SAMPLE_RATE = 48000; +export const GRAPH_PAD_Y = 14; // px headroom so ±12 dB nodes aren't clipped +const LOG_MIN = Math.log10(EQ_MIN_FREQUENCY); +const LOG_MAX = Math.log10(EQ_MAX_FREQUENCY); +const LOG_SPAN = LOG_MAX - LOG_MIN; + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)); +} + +export function freqToX(freq: number, width: number): number { + const f = clamp(freq, EQ_MIN_FREQUENCY, EQ_MAX_FREQUENCY); + return ((Math.log10(f) - LOG_MIN) / LOG_SPAN) * width; +} + +export function xToFreq(x: number, width: number): number { + const t = clamp(width > 0 ? x / width : 0, 0, 1); + return 10 ** (LOG_MIN + t * LOG_SPAN); +} + +export function gainToY(gainDb: number, height: number): number { + const center = height / 2; + const usable = center - GRAPH_PAD_Y; + return center - (clamp(gainDb, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB) / EQ_MAX_GAIN_DB) * usable; +} + +export function yToGain(y: number, height: number): number { + const center = height / 2; + const usable = center - GRAPH_PAD_Y; + if (usable <= 0) return 0; + return clamp(((center - y) / usable) * EQ_MAX_GAIN_DB, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB); +} + +/** Combined response curve as a stroked SkPath sampled across the width. */ +export function buildResponsePath( + bands: readonly EQBand[], + width: number, + height: number, + samples = 96 +): SkPath { + const path = Skia.Path.Make(); + if (width <= 0 || height <= 0) return path; + for (let i = 0; i <= samples; i++) { + const x = (i / samples) * width; + const freq = xToFreq(x, width); + const db = computeCombinedEQMagnitude(bands, freq, GRAPH_SAMPLE_RATE); + const y = gainToY(db, height); + if (i === 0) path.moveTo(x, y); + else path.lineTo(x, y); + } + return path; +} + +/** Closes a copy of the response path down to the baseline for a soft fill. */ +export function buildResponseFill(line: SkPath, width: number, height: number): SkPath { + const fill = line.copy(); + fill.lineTo(width, height / 2); + fill.lineTo(0, height / 2); + fill.close(); + return fill; +} + +/** Frequency gridline positions + labels shown along the bottom axis. */ +export const FREQ_TICKS: { freq: number; label: string }[] = [ + { freq: 30, label: '30' }, + { freq: 100, label: '100' }, + { freq: 500, label: '500' }, + { freq: 1000, label: '1k' }, + { freq: 5000, label: '5k' }, + { freq: 15000, label: '15k' }, +]; diff --git a/src/components/eq/format.ts b/src/components/eq/format.ts new file mode 100644 index 0000000..a337660 --- /dev/null +++ b/src/components/eq/format.ts @@ -0,0 +1,31 @@ +// Shared EQ value formatting for the band strip + detail panel. + +import { colors } from '@/theme'; +import type { EQBandType } from '@/types/audio'; + +export function formatFreq(hz: number): string { + if (hz >= 1000) { + const k = hz / 1000; + return `${Number.isInteger(k) ? k : k.toFixed(1)}k`; + } + return `${Math.round(hz)}`; +} + +export function formatGain(db: number): string { + if (Math.abs(db) < 0.05) return '0'; + return `${db > 0 ? '+' : ''}${db.toFixed(1)}`; +} + +export function gainColor(db: number): string { + if (db > 0.05) return colors.accentText; + if (db < -0.05) return colors.warning; + return colors.textTertiary; +} + +export const BAND_TYPE_LABEL: Record = { + lowshelf: 'Low Shelf', + peaking: 'Peaking', + highshelf: 'High Shelf', + highpass: 'High Pass', + lowpass: 'Low Pass', +}; diff --git a/src/components/library/ScanProgress.tsx b/src/components/library/ScanProgress.tsx index 5fda644..0a3d2e7 100644 --- a/src/components/library/ScanProgress.tsx +++ b/src/components/library/ScanProgress.tsx @@ -11,14 +11,16 @@ export function ScanProgress() { if (!isScanning) return null; const label = - progress.phase === 'extracting' - ? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}` - : progress.total > 0 - ? `Found ${progress.total} files in ${progress.folderName ?? ''}…` - : `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`; + progress.phase === 'analyzing' + ? `Analyzing audio… ${progress.processed}/${progress.total}` + : progress.phase === 'extracting' + ? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}` + : progress.total > 0 + ? `Found ${progress.total} files in ${progress.folderName ?? ''}…` + : `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`; const fraction = - progress.phase === 'extracting' && progress.total > 0 + (progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0 ? progress.processed / progress.total : 0; diff --git a/src/db/queries.ts b/src/db/queries.ts index dd040a0..cc288b1 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -141,6 +141,74 @@ export async function getTrackCount(db: LibraryDatabase): Promise { return row?.count ?? 0; } +// --- Loudness (M4 normalization facts) --------------------------------------- + +export interface TrackLoudness { + loudness_lufs: number | null; + sample_peak: number | null; + replay_gain_track_db: number | null; + replay_gain_album_db: number | null; + replay_gain_track_peak: number | null; + replay_gain_album_peak: number | null; + /** 1 once ReplayGain tags have been read (whether or not any were present). */ + rg_scanned: number | null; +} + +/** Loudness facts for one track path (NULL fields = not yet analyzed). */ +export async function getTrackLoudness( + db: LibraryDatabase, + path: string +): Promise { + return ( + (await db.get( + `SELECT loudness_lufs, sample_peak, + replay_gain_track_db, replay_gain_album_db, + replay_gain_track_peak, replay_gain_album_peak, rg_scanned + FROM tracks WHERE path = ?`, + [path] + )) ?? null + ); +} + +/** Persist measured loudness + sample peak for a track (scan analyze pass). */ +export async function setTrackLoudness( + db: LibraryDatabase, + path: string, + lufs: number | null, + samplePeak: number | null +): Promise { + await db.run('UPDATE tracks SET loudness_lufs = ?, sample_peak = ? WHERE path = ?', [ + lufs, + samplePeak, + path, + ]); +} + +export interface ReplayGainColumns { + trackGainDb: number | null; + albumGainDb: number | null; + trackPeak: number | null; + albumPeak: number | null; +} + +/** + * Persist ReplayGain tags read from the container + mark the track as scanned, so + * we read tags once per track (independent of loudness, which may re-measure). + */ +export async function setTrackReplayGain( + db: LibraryDatabase, + path: string, + rg: ReplayGainColumns +): Promise { + await db.run( + `UPDATE tracks SET + replay_gain_track_db = ?, replay_gain_album_db = ?, + replay_gain_track_peak = ?, replay_gain_album_peak = ?, rg_scanned = 1 + WHERE path = ?`, + [rg.trackGainDb, rg.albumGainDb, rg.trackPeak, rg.albumPeak, path] + ); +} + // --- Settings (key-value preferences) ---------------------------------------- export async function getSetting(db: LibraryDatabase, key: string): Promise { diff --git a/src/db/schema.ts b/src/db/schema.ts index 4214bc8..12eec69 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,11 +4,16 @@ // non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts); // v4 adds a key-value settings table (artist grouping mode, future prefs); // v5 caches offline waveform peaks for the M3 waveform seek bar; v6 repairs DBs -// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`. +// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`; +// v7 (M4) adds per-track loudness facts (integrated LUFS + sample peak + ReplayGain +// tags) measured for normalization; v8 clears any loudness measured by the earlier +// ungated whole-file method so it re-measures with the fast gated subset method; +// v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs +// once per track (and is retried if it ever failed), independent of loudness. import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 6; +export const SCHEMA_VERSION = 9; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -115,6 +120,27 @@ const MIGRATIONS: readonly (readonly string[])[] = [ )`, `DROP TABLE IF EXISTS waveform_cache`, ], + // v6 -> v7 — per-track loudness facts for normalization (M4). NULL = not yet + // analyzed (the scan analyze pass / lazy fallback backfills them). loudness_lufs + // is integrated LUFS (negative dB); sample_peak is linear [0,1]; replay_gain_* + // are tag dB values when present. + [ + `ALTER TABLE tracks ADD COLUMN loudness_lufs REAL`, + `ALTER TABLE tracks ADD COLUMN sample_peak REAL`, + `ALTER TABLE tracks ADD COLUMN replay_gain_track_db REAL`, + `ALTER TABLE tracks ADD COLUMN replay_gain_album_db REAL`, + ], + // v7 -> v8 — re-measure loudness with the gated subset method (the earlier values + // were ungated whole-file). NULL forces the background pass to recompute them. + [`UPDATE tracks SET loudness_lufs = NULL, sample_peak = NULL`], + // v8 -> v9 — ReplayGain peaks (linear, for clip-limiting in RG mode) + an + // `rg_scanned` flag (0 = tags not yet read). Tag reading is decoupled from the + // loudness decode so it runs once per track and survives loudness re-measures. + [ + `ALTER TABLE tracks ADD COLUMN replay_gain_track_peak REAL`, + `ALTER TABLE tracks ADD COLUMN replay_gain_album_peak REAL`, + `ALTER TABLE tracks ADD COLUMN rg_scanned INTEGER NOT NULL DEFAULT 0`, + ], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/library/scanner.ts b/src/library/scanner.ts index 57f2778..f6dce26 100644 --- a/src/library/scanner.ts +++ b/src/library/scanner.ts @@ -23,7 +23,7 @@ import { metadataToUpsertRow } from './trackAdapter'; const EXTRACT_BATCH_SIZE = 24; export interface ScanProgress { - phase: 'discovering' | 'extracting'; + phase: 'discovering' | 'extracting' | 'analyzing'; processed: number; total: number; folderName: string; @@ -157,6 +157,10 @@ export async function scanFolder( } await markFolderScanned(db, folder.id); + + // Loudness + waveform are measured on the fly: the first time a track is played, + // useNormalizationSync (loudness) and the seek bar (waveform) decode + cache it. + // No bulk background decoding — gentle on low-end devices. return result; } diff --git a/src/scope/oscilloscopeGain.ts b/src/scope/oscilloscopeGain.ts new file mode 100644 index 0000000..63280e5 --- /dev/null +++ b/src/scope/oscilloscopeGain.ts @@ -0,0 +1,32 @@ +// Per-track oscilloscope display gain. +// +// The oscilloscope tap sits right after the normalization processor, so each track +// arrives at a different level (loudness normalization scales quiet and loud masters +// by different amounts). A single fixed display gain therefore buries quiet tracks +// and clips loud ones. Instead we pick ONE gain per track that maps the track's peak +// to a consistent display height, and hold it for the whole track — so the song's own +// quiet/loud dynamics are preserved (a constant multiplier doesn't change them) while +// every track lines up to the same reference. It only changes when the track changes. + +/** Fallback when the track's peak is unknown (not yet analyzed / untagged). */ +export const DEFAULT_OSC_GAIN = 1.8; + +// Map the track's (post-normalization) peak to this fraction of the half-height, +// leaving a little headroom so the line doesn't kiss the edges. +const TARGET_LEVEL = 0.85; +const MIN_OSC_GAIN = 0.5; +const MAX_OSC_GAIN = 8; // cap so a very quiet master doesn't blow up to noise + +/** + * Display gain for the oscilloscope given the track's pre-normalization linear peak + * and the normalization gain currently applied (the tap is post-normalization, so the + * level it sees is `basePeak * normGain`). Returns {@link DEFAULT_OSC_GAIN} when the + * peak is unknown. + */ +export function computeOscilloscopeGain(basePeak: number | null, normGain: number): number { + if (basePeak == null || !(basePeak > 0)) return DEFAULT_OSC_GAIN; + const postNormPeak = basePeak * (normGain > 0 ? normGain : 1); + if (!(postNormPeak > 0)) return DEFAULT_OSC_GAIN; + const gain = TARGET_LEVEL / postNormPeak; + return Math.max(MIN_OSC_GAIN, Math.min(MAX_OSC_GAIN, gain)); +} diff --git a/src/scope/scopeStore.ts b/src/scope/scopeStore.ts index 44462ed..85486aa 100644 --- a/src/scope/scopeStore.ts +++ b/src/scope/scopeStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { DEFAULT_OSC_GAIN } from './oscilloscopeGain'; /** * Whether the visualizers should run. Set by useScopeLifecycle (foreground + @@ -8,11 +9,20 @@ import { create } from 'zustand'; interface ScopeStore { active: boolean; setActive: (active: boolean) => void; + /** + * Per-track oscilloscope display gain. Set once per track by useNormalizationSync + * (from the track's peak + the normalization gain) and read each frame by the + * oscilloscope so the level is consistent across tracks but constant within one. + */ + oscGain: number; + setOscGain: (gain: number) => void; } export const useScopeStore = create((set) => ({ active: false, setActive: (active) => set({ active }), + oscGain: DEFAULT_OSC_GAIN, + setOscGain: (oscGain) => set({ oscGain }), })); export const useScopeActive = (): boolean => useScopeStore((s) => s.active); diff --git a/src/stores/audioSettingsStore.ts b/src/stores/audioSettingsStore.ts new file mode 100644 index 0000000..4016c32 --- /dev/null +++ b/src/stores/audioSettingsStore.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { + DEFAULT_TARGET_LUFS, + type NormalizationSettings, + type ReplayGainMode, +} from '@/audio/normalization'; + +/** + * Loudness/normalization + ReplayGain preferences. SQLite (settings table) is the + * source of truth, mirrored in memory (settingsStore pattern; no zustand-persist). + * The EQ's own state lives in eqStore — this store is the normalization half. + */ +const NORMALIZATION_ENABLED_KEY = 'normalization_enabled'; +const NORMALIZATION_TARGET_KEY = 'normalization_target_lufs'; +const REPLAYGAIN_ENABLED_KEY = 'replaygain_enabled'; +const REPLAYGAIN_MODE_KEY = 'replaygain_mode'; + +function parseMode(value: string | null): ReplayGainMode { + return value === 'track' || value === 'album' ? value : 'auto'; +} + +interface AudioSettingsStore { + normalizationEnabled: boolean; + normalizationTargetLufs: number; + replayGainEnabled: boolean; + replayGainMode: ReplayGainMode; + loaded: boolean; + + load: () => Promise; + setNormalizationEnabled: (enabled: boolean) => Promise; + setNormalizationTargetLufs: (lufs: number) => Promise; + setReplayGainEnabled: (enabled: boolean) => Promise; + setReplayGainMode: (mode: ReplayGainMode) => Promise; + /** Current settings as the plain shape the gain resolver consumes. */ + asNormalizationSettings: () => NormalizationSettings; +} + +export const useAudioSettingsStore = create((set, get) => ({ + normalizationEnabled: true, + normalizationTargetLufs: DEFAULT_TARGET_LUFS, + replayGainEnabled: false, + replayGainMode: 'auto', + loaded: false, + + load: async () => { + if (get().loaded) return; + const db = await openLibraryDb(); + const [enabled, target, rgEnabled, rgMode] = await Promise.all([ + getSetting(db, NORMALIZATION_ENABLED_KEY), + getSetting(db, NORMALIZATION_TARGET_KEY), + getSetting(db, REPLAYGAIN_ENABLED_KEY), + getSetting(db, REPLAYGAIN_MODE_KEY), + ]); + const targetNum = Number(target); + set({ + // Defaults to ON when never set. + normalizationEnabled: enabled === null ? true : enabled === 'true', + normalizationTargetLufs: Number.isFinite(targetNum) && target !== null ? targetNum : DEFAULT_TARGET_LUFS, + replayGainEnabled: rgEnabled === 'true', + replayGainMode: parseMode(rgMode), + loaded: true, + }); + }, + + setNormalizationEnabled: async (enabled) => { + if (get().normalizationEnabled === enabled) return; + set({ normalizationEnabled: enabled }); + const db = await openLibraryDb(); + await setSetting(db, NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false'); + }, + + setNormalizationTargetLufs: async (lufs) => { + const clamped = Math.max(-30, Math.min(-5, lufs)); + if (get().normalizationTargetLufs === clamped) return; + set({ normalizationTargetLufs: clamped }); + const db = await openLibraryDb(); + await setSetting(db, NORMALIZATION_TARGET_KEY, String(clamped)); + }, + + setReplayGainEnabled: async (enabled) => { + if (get().replayGainEnabled === enabled) return; + set({ replayGainEnabled: enabled }); + const db = await openLibraryDb(); + await setSetting(db, REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false'); + }, + + setReplayGainMode: async (mode) => { + if (get().replayGainMode === mode) return; + set({ replayGainMode: mode }); + const db = await openLibraryDb(); + await setSetting(db, REPLAYGAIN_MODE_KEY, mode); + }, + + asNormalizationSettings: () => { + const s = get(); + return { + enabled: s.normalizationEnabled, + targetLufs: s.normalizationTargetLufs, + replayGainEnabled: s.replayGainEnabled, + replayGainMode: s.replayGainMode, + }; + }, +})); diff --git a/src/stores/eqStore.ts b/src/stores/eqStore.ts index 1441a70..4e92e71 100644 --- a/src/stores/eqStore.ts +++ b/src/stores/eqStore.ts @@ -1,44 +1,280 @@ import { create } from 'zustand'; -import type { EQBand } from '@/types/audio'; +import type { EQBand, EQPreset } from '@/types/audio'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { + EQ_MAX_BANDS, + clampEQFrequency, + clampEQGain, + clampEQQ, + clampPreamp, + createNormalizedEQBand, + dbToLinear, + flattenBandsForNative, +} from '@/audio/eq'; +import { createBuiltInPresets, createDefaultBands, FLAT_PRESET_ID, genEqId } from '@/audio/eqPresets'; +import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative'; /** - * EQ state — M0 stub. The biquad chain is implemented as a Media3 AudioProcessor - * at M4; for now this just holds the band model (ported `EQBand`) so the EQ - * screen and later DSP wiring share one shape. + * Parametric EQ state — SQLite (settings table) is the source of truth, mirrored + * in memory (mirrors the settingsStore pattern; no zustand-persist). Every band/ + * preamp/enable change pushes params to the native EqAudioProcessor via _syncToNative + * (immediate, for live audio) and debounce-persists to SQLite. */ -const DEFAULT_FREQUENCIES = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; +const ENABLED_KEY = 'eq_enabled'; +const PREAMP_KEY = 'eq_preamp'; +const BANDS_KEY = 'eq_bands'; +const ACTIVE_PRESET_KEY = 'eq_active_preset'; +const CUSTOM_PRESETS_KEY = 'eq_custom_presets'; -function makeDefaultBands(): EQBand[] { - return DEFAULT_FREQUENCIES.map((frequency) => ({ - id: `band-${frequency}`, - type: 'peaking', - frequency, - gain: 0, - Q: 1.0, - })); +const PERSIST_DEBOUNCE_MS = 250; + +function parseBands(json: string | null): EQBand[] | null { + if (!json) return null; + try { + const arr = JSON.parse(json); + if (!Array.isArray(arr) || arr.length === 0) return null; + return arr.slice(0, EQ_MAX_BANDS).map((raw) => createNormalizedEQBand(raw, genEqId())); + } catch { + return null; + } +} + +function parseCustomPresets(json: string | null): EQPreset[] { + if (!json) return []; + try { + const arr = JSON.parse(json); + if (!Array.isArray(arr)) return []; + return arr + .filter((p): p is { id?: string; name: string; preamp?: number; bands?: unknown[] } => !!p && typeof p.name === 'string') + .map((p) => ({ + // Keep the stored id so a persisted activePresetId still matches on reload. + id: typeof p.id === 'string' && p.id.length > 0 ? p.id : genEqId(), + name: p.name, + preamp: clampPreamp(typeof p.preamp === 'number' ? p.preamp : 0), + bands: Array.isArray(p.bands) + ? p.bands.slice(0, EQ_MAX_BANDS).map((b) => createNormalizedEQBand(b as object, genEqId())) + : createDefaultBands(), + isCustom: true, + })); + } catch { + return []; + } } interface EQStore { enabled: boolean; preamp: number; // dB bands: EQBand[]; + presets: EQPreset[]; // built-in + custom + activePresetId: string | null; // null = manually edited ("Custom") + activeBandId: string | null; // UI selection shared by curve / strip / panel + loaded: boolean; + load: () => Promise; setEnabled: (enabled: boolean) => void; - setPreamp: (preamp: number) => void; - setBandGain: (id: string, gain: number) => void; - reset: () => void; + toggleEnabled: () => void; + setPreamp: (db: number) => void; + addBand: (band?: Partial) => void; + removeBand: (id: string) => void; + updateBand: (id: string, updates: Partial) => void; + selectBand: (id: string | null) => void; + applyPreset: (presetId: string) => void; + resetToFlat: () => void; + saveCustomPreset: (name: string) => void; + deleteCustomPreset: (presetId: string) => void; + importPreset: (preset: EQPreset) => void; + + _syncToNative: () => void; } -export const useEQStore = create((set) => ({ - enabled: false, - preamp: 0, - bands: makeDefaultBands(), +let persistTimer: ReturnType | null = null; - setEnabled: (enabled) => set({ enabled }), - setPreamp: (preamp) => set({ preamp }), - setBandGain: (id, gain) => - set((state) => ({ - bands: state.bands.map((b) => (b.id === id ? { ...b, gain } : b)), - })), - reset: () => set({ enabled: false, preamp: 0, bands: makeDefaultBands() }), -})); +export const useEQStore = create((set, get) => { + function syncToNative(): void { + const { enabled, preamp, bands } = get(); + setEqEnabledNative(enabled); + setEqPreampNative(enabled ? dbToLinear(preamp) : 1); + setEqBandsNative(flattenBandsForNative(bands)); + } + + function schedulePersist(): void { + if (persistTimer) clearTimeout(persistTimer); + persistTimer = setTimeout(() => { + persistTimer = null; + void persistNow(); + }, PERSIST_DEBOUNCE_MS); + } + + async function persistNow(): Promise { + const { enabled, preamp, bands, activePresetId, presets } = get(); + const custom = presets.filter((p) => p.isCustom); + try { + const db = await openLibraryDb(); + await Promise.all([ + setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'), + setSetting(db, PREAMP_KEY, String(preamp)), + setSetting(db, BANDS_KEY, JSON.stringify(bands)), + setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''), + setSetting( + db, + CUSTOM_PRESETS_KEY, + JSON.stringify( + custom.map((p) => ({ id: p.id, name: p.name, preamp: p.preamp, bands: p.bands })) + ) + ), + ]); + } catch { + /* persistence failure is non-fatal */ + } + } + + /** Mark the current band set as a manual edit (no longer matches a preset). */ + function markEdited(patch: Partial): void { + set({ ...patch, activePresetId: null }); + syncToNative(); + schedulePersist(); + } + + return { + enabled: false, + preamp: 0, + bands: createDefaultBands(), + presets: createBuiltInPresets(), + activePresetId: FLAT_PRESET_ID, + activeBandId: null, + loaded: false, + + load: async () => { + if (get().loaded) return; + const db = await openLibraryDb(); + const [enabledRaw, preampRaw, bandsRaw, activeRaw, customRaw] = await Promise.all([ + getSetting(db, ENABLED_KEY), + getSetting(db, PREAMP_KEY), + getSetting(db, BANDS_KEY), + getSetting(db, ACTIVE_PRESET_KEY), + getSetting(db, CUSTOM_PRESETS_KEY), + ]); + + const bands = parseBands(bandsRaw) ?? createDefaultBands(); + const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)]; + const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null; + + set({ + enabled: enabledRaw === 'true', + preamp: clampPreamp(Number(preampRaw) || 0), + bands, + presets, + // Stored active preset ids are regenerated on load (parseCustomPresets makes + // new ids), so only built-in ids survive a reload; fall back to "Custom". + activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null, + activeBandId: bands[0]?.id ?? null, + loaded: true, + }); + syncToNative(); + }, + + setEnabled: (enabled) => { + set({ enabled }); + syncToNative(); + schedulePersist(); + }, + + toggleEnabled: () => { + set({ enabled: !get().enabled }); + syncToNative(); + schedulePersist(); + }, + + setPreamp: (db) => markEdited({ preamp: clampPreamp(db) }), + + addBand: (partial) => { + const { bands } = get(); + if (bands.length >= EQ_MAX_BANDS) return; + const band = createNormalizedEQBand( + { + type: partial?.type ?? 'peaking', + frequency: partial?.frequency ?? 1000, + gain: partial?.gain ?? 0, + Q: partial?.Q ?? 1.0, + enabled: partial?.enabled ?? true, + }, + genEqId() + ); + const next = [...bands, band].sort((a, b) => a.frequency - b.frequency); + markEdited({ bands: next, activeBandId: band.id }); + }, + + removeBand: (id) => { + const { bands, activeBandId } = get(); + if (bands.length <= 1) return; + const next = bands.filter((b) => b.id !== id); + markEdited({ + bands: next, + activeBandId: activeBandId === id ? (next[0]?.id ?? null) : activeBandId, + }); + }, + + updateBand: (id, updates) => { + const next = get().bands.map((b) => { + if (b.id !== id) return b; + const merged: EQBand = { ...b, ...updates }; + if (updates.frequency !== undefined) merged.frequency = clampEQFrequency(updates.frequency); + if (updates.gain !== undefined) merged.gain = clampEQGain(updates.gain); + if (updates.Q !== undefined) merged.Q = clampEQQ(updates.Q); + return merged; + }); + markEdited({ + bands: updates.frequency !== undefined ? next.sort((a, b) => a.frequency - b.frequency) : next, + }); + }, + + selectBand: (id) => set({ activeBandId: id }), + + applyPreset: (presetId) => { + const preset = get().presets.find((p) => p.id === presetId); + if (!preset) return; + const bands = preset.bands.map((b) => createNormalizedEQBand(b, genEqId())); + set({ + bands, + preamp: clampPreamp(preset.preamp), + activePresetId: presetId, + activeBandId: bands[0]?.id ?? null, + }); + syncToNative(); + schedulePersist(); + }, + + resetToFlat: () => get().applyPreset(FLAT_PRESET_ID), + + saveCustomPreset: (name) => { + const { bands, preamp, presets } = get(); + const preset: EQPreset = { + id: genEqId(), + name: name.trim() || 'Custom Preset', + preamp, + bands: bands.map((b) => ({ ...b, id: genEqId() })), + isCustom: true, + }; + set({ presets: [...presets, preset], activePresetId: preset.id }); + schedulePersist(); + }, + + deleteCustomPreset: (presetId) => { + const { presets, activePresetId } = get(); + set({ + presets: presets.filter((p) => p.id !== presetId), + activePresetId: activePresetId === presetId ? null : activePresetId, + }); + schedulePersist(); + }, + + importPreset: (preset) => { + const stored: EQPreset = { ...preset, id: genEqId(), isCustom: true }; + set({ presets: [...get().presets, stored] }); + get().applyPreset(stored.id); + }, + + _syncToNative: syncToNative, + }; +}); diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 7b60294..df3e9c2 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -25,7 +25,7 @@ type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders'; export type FolderWithCount = LibraryFolder & { track_count: number }; interface ScanProgressState { - phase: 'idle' | 'discovering' | 'extracting'; + phase: 'idle' | 'discovering' | 'extracting' | 'analyzing'; processed: number; total: number; folderName?: string; diff --git a/src/types/audio.ts b/src/types/audio.ts index d5ebb55..9cecbe6 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -52,12 +52,17 @@ export interface PlayerState { } // EQ Band +export type EQBandType = 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass'; + export interface EQBand { id: string; - type: 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass'; + type: EQBandType; frequency: number; gain: number; Q: number; + // Per-band bypass (mobile addition vs desktop — the EQ screen's per-band On toggle). + // A disabled band is passthrough and is skipped in the response curve. + enabled: boolean; } // EQ Preset diff --git a/src/types/library.ts b/src/types/library.ts index c21cc61..03dd6d8 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -30,6 +30,12 @@ export interface DbTrack { mtime: number; added_at: number; modified_at: number; + // M4 loudness facts (NULL until analyzed). loudness_lufs: integrated LUFS (dB, + // negative); sample_peak: linear [0,1]; replay_gain_*: tag dB when present. + loudness_lufs: number | null; + sample_peak: number | null; + replay_gain_track_db: number | null; + replay_gain_album_db: number | null; } export interface LibraryFolder { diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/7a308bbc4fe84868415859cf0cc55834/transformed/classes/classes_dex/classes.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/7a308bbc4fe84868415859cf0cc55834/transformed/classes/classes_dex/classes.dex index e8ea8d5..3284937 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/7a308bbc4fe84868415859cf0cc55834/transformed/classes/classes_dex/classes.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/7a308bbc4fe84868415859cf0cc55834/transformed/classes/classes_dex/classes.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.dex index 2edd38a..7688c4c 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.dex index eacddbb..f61460a 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.dex index 8ff4f46..a7d930d 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.dex index 5a7e491..9f46c81 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.dex index 1a569f7..d226538 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.dex index a026324..7e9bb2a 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.dex index 1f8f79c..375de2f 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.dex b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.dex index 65e754b..8f8af82 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.dex and b/vendor/kotlinaudio/kotlin-audio/build/.transforms/efb325046c0dd0f6a51032165627c60b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.dex differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/vendor/kotlinaudio/kotlin-audio/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar index 24ee9a6..528c8f8 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/vendor/kotlinaudio/kotlin-audio/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties index 3a496aa..df8e87f 100644 --- a/vendor/kotlinaudio/kotlin-audio/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +++ b/vendor/kotlinaudio/kotlin-audio/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties @@ -1 +1 @@ -#Wed Jun 17 14:38:54 EDT 2026 +#Fri Jun 19 12:51:01 EDT 2026 diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class index 8d57ddc..26b55d7 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class index 125b5cb..89e98a4 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class index 8780c9a..7dc00fa 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class index 463ae3b..35a1888 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class index e6a2875..777d6f4 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class index 1014987..2397e2e 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class index 77d4287..c8373c3 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class index 33360a1..a7c0419 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar index 8091687..6cfec4b 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar and b/vendor/kotlinaudio/kotlin-audio/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab index 74dca0f..f73e3cc 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream index bfab74d..ddf2197 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len index 7acc452..87807f1 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len index 3085af4..1b7c1f8 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at index 5d2f238..2f82378 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i index e5bbf4d..43de4dc 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab index f81f9ba..455f5ab 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream index f4e73d8..daefbf0 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len index 952c5d6..18051b3 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len index 74768ff..04a2552 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at index ebbcfcf..664da0c 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i index ea78307..32926fd 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab index 27e3880..e1301df 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream index f4e73d8..daefbf0 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len index 952c5d6..18051b3 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len index 74768ff..04a2552 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at index 4825168..e4d2e09 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i index ea78307..32926fd 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab index 5582096..8346f5d 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream index cecf6e2..6143aa5 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len index e6f5dea..9bce7ee 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len index 8a37598..5672451 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at index ddf6abe..ca61cd4 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i index 9f54bd4..ad01e11 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab index 47b5f64..ae2fd28 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream index c405bbc..32a90f7 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len index d9ceda7..79004b7 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len index 2553f37..e5d59a2 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at index 7fbfa68..dbb9f34 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i index 58636fb..42795a6 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab index c2fb241..7833f85 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream index bfab74d..ddf2197 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len index 7acc452..87807f1 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len index 3085af4..1b7c1f8 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at index 6d8a596..3706f9c 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i index e5bbf4d..43de4dc 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at index 27944a7..54225d5 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab index 37f87d0..12e4443 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream index ea60ad8..81aac8a 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len index 8fa92eb..e0cad94 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len index 60e54ab..b4da131 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at index 109a406..525a865 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i index 99b6109..977c319 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab index 5007100..2dce5b8 100644 --- a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab +++ b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab @@ -1,2 +1,2 @@ -31 +34 0 \ No newline at end of file diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab index 1b86ab7..d405140 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream index bfab74d..ddf2197 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len index 7acc452..87807f1 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len index 3085af4..1b7c1f8 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at index ca46eca..632ffbe 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i index e5bbf4d..43de4dc 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab index 331188a..85468cc 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream index 31a0c7e..eee417c 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len index b01f22d..4b05c55 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len index 3085af4..1b7c1f8 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at index 34d02d6..f593911 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i index aa13d7e..3056823 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab index 76ac1f3..24c216b 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream index 6423b6e..bd37872 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len index f4c29f5..c357136 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len index 161e5d5..5a3d380 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at index 1bc8208..90a1ba6 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i index 5487aa9..f8c248b 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/last-build.bin b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/last-build.bin index 2002b51..fa6a3d2 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/last-build.bin and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/cacheable/last-build.bin differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin index d573ab8..0ec4e2a 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/local-state/build-history.bin b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/local-state/build-history.bin index 7de3451..844f804 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/local-state/build-history.bin and b/vendor/kotlinaudio/kotlin-audio/build/kotlin/compileDebugKotlin/local-state/build-history.bin differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class index 8d57ddc..26b55d7 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$2.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class index 125b5cb..89e98a4 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$Companion.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class index 8780c9a..7dc00fa 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$PlayerListener.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class index 463ae3b..35a1888 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$createForwardingPlayer$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class index e6a2875..777d6f4 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer$ratingType$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class index 1014987..2397e2e 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class index 77d4287..c8373c3 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt$buildScopeRenderersFactory$1.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class index 33360a1..a7c0419 100644 Binary files a/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class and b/vendor/kotlinaudio/kotlin-audio/build/tmp/kotlin-classes/debug/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactoryKt.class differ diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt index 17c550b..695a726 100644 --- a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt @@ -24,6 +24,7 @@ import com.doublesymmetry.kotlinaudio.models.AudioItem import com.doublesymmetry.kotlinaudio.models.AudioItemHolder import com.doublesymmetry.kotlinaudio.models.AudioItemTransitionReason import com.doublesymmetry.kotlinaudio.models.AudioPlayerState +import expo.modules.astrascope.GainBridge import com.doublesymmetry.kotlinaudio.models.BufferConfig import com.doublesymmetry.kotlinaudio.models.CacheConfig import com.doublesymmetry.kotlinaudio.models.DefaultPlayerOptions @@ -666,6 +667,12 @@ abstract class BaseAudioPlayer internal constructor( * playlist becomes non-empty or empty as a consequence of a playlist change. */ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + // Apply the per-track normalization gain natively, exactly at the audio + // transition — the gain map is pre-seeded from JS by URL, so this is just a + // lock-free lookup (no JS round-trip on track change). + val url = runCatching { mediaItem?.getAudioItemHolder()?.audioItem?.audioUrl }.getOrNull() + GainBridge.activateFor(url) + when (reason) { Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> playerEventHolder.updateAudioItemTransition( AudioItemTransitionReason.AUTO(oldPosition) diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt new file mode 100644 index 0000000..e5fbcc7 --- /dev/null +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqAudioProcessor.kt @@ -0,0 +1,258 @@ +package com.doublesymmetry.kotlinaudio.scope + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.audio.AudioProcessor +import com.google.android.exoplayer2.audio.BaseAudioProcessor +import expo.modules.astrascope.EqBridge +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Parametric EQ as an ExoPlayer AudioProcessor (M4). Reads raw band params from + * [EqBridge] (set from JS) and computes Audio-EQ-Cookbook biquad coefficients at + * the real stream sample rate — mirroring Web Audio's BiquadFilterNode on desktop. + * A cascade of transposed-direct-form-II biquads runs per channel after a preamp. + * + * Passthrough (bit-exact) when the EQ is disabled or has no active bands and unity + * preamp, so toggling EQ off is lossless. Handles PCM float and 16-bit; coefficients + * are rebuilt only when [EqBridge.revision] changes (cheap, off the per-sample path). + */ +class EqAudioProcessor : BaseAudioProcessor() { + private var channels = 0 + private var sampleRate = 0f + + private var lastRevision = Int.MIN_VALUE + private var enabled = false + private var preamp = 1f + private var bandCount = 0 + private var coeffs = FloatArray(0) // 5 per band: b0,b1,b2,a1,a2 (a0-normalized) + private var z1 = FloatArray(0) // bandCount * channels + private var z2 = FloatArray(0) + + private var floatScratch = FloatArray(0) + + override fun onConfigure( + inputAudioFormat: AudioProcessor.AudioFormat + ): AudioProcessor.AudioFormat { + channels = inputAudioFormat.channelCount + sampleRate = inputAudioFormat.sampleRate.toFloat() + lastRevision = Int.MIN_VALUE // force a rebuild on the next buffer + return inputAudioFormat + } + + override fun queueInput(inputBuffer: ByteBuffer) { + val remaining = inputBuffer.remaining() + if (remaining <= 0) return + + rebuildIfNeeded() + + val passthrough = !enabled || channels <= 0 || (bandCount == 0 && preamp == 1f) + if (passthrough) { + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() + return + } + + when (inputAudioFormat.encoding) { + C.ENCODING_PCM_FLOAT -> processFloat(inputBuffer, remaining) + C.ENCODING_PCM_16BIT -> process16(inputBuffer, remaining) + else -> { + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() + } + } + } + + override fun onFlush() { + z1.fill(0f) + z2.fill(0f) + } + + private fun rebuildIfNeeded() { + val rev = EqBridge.revision + if (rev == lastRevision) return + lastRevision = rev + + enabled = EqBridge.enabled + preamp = EqBridge.preampLinear + val params = EqBridge.bands + val total = params.size / 5 + + var active = 0 + for (i in 0 until total) if (params[i * 5 + 4] != 0f) active++ + + // Reset filter state only when the band count changes (avoid clicks on tweaks). + if (active != bandCount) { + bandCount = active + coeffs = FloatArray(active * 5) + z1 = FloatArray(active * channels.coerceAtLeast(1)) + z2 = FloatArray(active * channels.coerceAtLeast(1)) + } + + var bi = 0 + for (i in 0 until total) { + if (params[i * 5 + 4] == 0f) continue + computeCoeffs( + params[i * 5].toInt(), + params[i * 5 + 1], + params[i * 5 + 2], + params[i * 5 + 3], + sampleRate, + coeffs, + bi * 5 + ) + bi++ + } + } + + private fun processFloat(inputBuffer: ByteBuffer, remaining: Int) { + val fb = inputBuffer.asFloatBuffer() + val n = fb.remaining() + if (n <= 0) return + if (floatScratch.size < n) floatScratch = FloatArray(n) + fb.get(floatScratch, 0, n) + inputBuffer.position(inputBuffer.limit()) // mark input consumed + + processSamples(floatScratch, n) + + val out = replaceOutputBuffer(n * 4).order(ByteOrder.nativeOrder()) + out.asFloatBuffer().put(floatScratch, 0, n) + out.position(n * 4) + out.flip() + } + + private fun process16(inputBuffer: ByteBuffer, remaining: Int) { + val sb = inputBuffer.asShortBuffer() + val n = sb.remaining() + if (n <= 0) return + if (floatScratch.size < n) floatScratch = FloatArray(n) + var i = 0 + while (i < n) { + floatScratch[i] = sb.get(i) / 32768f + i++ + } + inputBuffer.position(inputBuffer.limit()) + + processSamples(floatScratch, n) + + val out = replaceOutputBuffer(n * 2).order(ByteOrder.nativeOrder()) + val osb = out.asShortBuffer() + i = 0 + while (i < n) { + val v = (floatScratch[i] * 32768f).roundToInt().coerceIn(-32768, 32767) + osb.put(v.toShort()) + i++ + } + out.position(n * 2) + out.flip() + } + + /** Apply preamp + the biquad cascade in place over interleaved samples. */ + private fun processSamples(buf: FloatArray, n: Int) { + val ch = channels + val bc = bandCount + val pre = preamp + var c = 0 + var i = 0 + while (i < n) { + var x = buf[i] * pre + var b = 0 + while (b < bc) { + val co = b * 5 + val b0 = coeffs[co] + val b1 = coeffs[co + 1] + val b2 = coeffs[co + 2] + val a1 = coeffs[co + 3] + val a2 = coeffs[co + 4] + val si = b * ch + c + val s1 = z1[si] + val s2 = z2[si] + val y = b0 * x + s1 + z1[si] = b1 * x - a1 * y + s2 + z2[si] = b2 * x - a2 * y + x = y + b++ + } + buf[i] = x + c++ + if (c == ch) c = 0 + i++ + } + } + + /** + * Audio-EQ-Cookbook biquad coefficients (a0-normalized) into out[off..off+4]. + * Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts: + * 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass. + */ + private fun computeCoeffs( + type: Int, + freq: Float, + gainDb: Float, + q: Float, + sr: Float, + out: FloatArray, + off: Int + ) { + if (sr <= 0f) { + out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f + return + } + val w0 = 2.0 * PI * freq / sr + val cosW0 = cos(w0) + val sinW0 = sin(w0) + val a = 10.0.pow(gainDb / 40.0) + val alpha = sinW0 / (2.0 * q.coerceAtLeast(0.0001f)) + + var b0 = 1.0; var b1 = 0.0; var b2 = 0.0 + var a0 = 1.0; var a1 = 0.0; var a2 = 0.0 + + when (type) { + 1 -> { // peaking + b0 = 1 + alpha * a; b1 = -2 * cosW0; b2 = 1 - alpha * a + a0 = 1 + alpha / a; a1 = -2 * cosW0; a2 = 1 - alpha / a + } + 0 -> { // lowshelf + val sqrtA = sqrt(a) + b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha) + b1 = 2 * a * (a - 1 - (a + 1) * cosW0) + b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha) + a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha + a1 = -2 * (a - 1 + (a + 1) * cosW0) + a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha + } + 2 -> { // highshelf + val sqrtA = sqrt(a) + b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha) + b1 = -2 * a * (a - 1 + (a + 1) * cosW0) + b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha) + a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha + a1 = 2 * (a - 1 - (a + 1) * cosW0) + a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha + } + 4 -> { // lowpass + b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2 + a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha + } + 3 -> { // highpass + b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2 + a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha + } + } + + val inv = 1.0 / a0 + out[off] = (b0 * inv).toFloat() + out[off + 1] = (b1 * inv).toFloat() + out[off + 2] = (b2 * inv).toFloat() + out[off + 3] = (a1 * inv).toFloat() + out[off + 4] = (a2 * inv).toFloat() + } +} diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/NormalizationGainProcessor.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/NormalizationGainProcessor.kt new file mode 100644 index 0000000..be74d12 --- /dev/null +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/NormalizationGainProcessor.kt @@ -0,0 +1,80 @@ +package com.doublesymmetry.kotlinaudio.scope + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.audio.AudioProcessor +import com.google.android.exoplayer2.audio.BaseAudioProcessor +import expo.modules.astrascope.GainBridge +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.roundToInt + +/** + * Applies the per-track normalization / ReplayGain gain from [GainBridge] (set from + * JS on track/settings change). Sits FIRST in the chain — before the scope taps — + * so the visualizers see normalized levels (the user's "better for scopes" goal). + * + * Bit-exact passthrough when the gain is unity. Handles PCM float and 16-bit; the + * 16-bit path clamps to int16 range. The gain resolver already backs off so the + * post-gain peak stays <= 0.98, so clipping should not occur here in practice. + */ +class NormalizationGainProcessor : BaseAudioProcessor() { + private var floatScratch = FloatArray(0) + + override fun onConfigure( + inputAudioFormat: AudioProcessor.AudioFormat + ): AudioProcessor.AudioFormat = inputAudioFormat + + override fun queueInput(inputBuffer: ByteBuffer) { + val remaining = inputBuffer.remaining() + if (remaining <= 0) return + + val gain = GainBridge.linearGain + if (gain == 1f) { + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() + return + } + + when (inputAudioFormat.encoding) { + C.ENCODING_PCM_FLOAT -> { + val fb = inputBuffer.asFloatBuffer() + val n = fb.remaining() + if (n <= 0) return + if (floatScratch.size < n) floatScratch = FloatArray(n) + fb.get(floatScratch, 0, n) + inputBuffer.position(inputBuffer.limit()) + var i = 0 + while (i < n) { + floatScratch[i] = floatScratch[i] * gain + i++ + } + val out = replaceOutputBuffer(n * 4).order(ByteOrder.nativeOrder()) + out.asFloatBuffer().put(floatScratch, 0, n) + out.position(n * 4) + out.flip() + } + C.ENCODING_PCM_16BIT -> { + val sb = inputBuffer.asShortBuffer() + val n = sb.remaining() + if (n <= 0) return + val out = replaceOutputBuffer(n * 2).order(ByteOrder.nativeOrder()) + val osb = out.asShortBuffer() + var i = 0 + while (i < n) { + val v = (sb.get(i) * gain).roundToInt().coerceIn(-32768, 32767) + osb.put(v.toShort()) + i++ + } + inputBuffer.position(inputBuffer.limit()) + out.position(n * 2) + out.flip() + } + else -> { + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() + } + } + } +} diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/PostEqTapAudioProcessor.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/PostEqTapAudioProcessor.kt new file mode 100644 index 0000000..53cdfd2 --- /dev/null +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/PostEqTapAudioProcessor.kt @@ -0,0 +1,66 @@ +package com.doublesymmetry.kotlinaudio.scope + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.audio.AudioProcessor +import com.google.android.exoplayer2.audio.BaseAudioProcessor +import expo.modules.astrascope.ScopeBridge +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Pass-through tap placed AFTER the EQ processor (M4). Identical to + * ScopeTapAudioProcessor but pushes to the native post-EQ ring (ring #2) which + * feeds the EQ screen's response-curve spectrum overlay. Gated by + * `ScopeBridge.active && ScopeBridge.postEqActive` so it costs ~zero unless the + * EQ screen is open and the app is foregrounded + playing. + */ +class PostEqTapAudioProcessor : BaseAudioProcessor() { + private var scratch = FloatArray(0) + + override fun onConfigure( + inputAudioFormat: AudioProcessor.AudioFormat + ): AudioProcessor.AudioFormat = inputAudioFormat + + override fun queueInput(inputBuffer: ByteBuffer) { + val remaining = inputBuffer.remaining() + if (remaining <= 0) return + + if (ScopeBridge.active && ScopeBridge.postEqActive) { + tap(inputBuffer) + } + + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() + } + + private fun tap(inputBuffer: ByteBuffer) { + val channels = inputAudioFormat.channelCount + if (channels <= 0) return + val dup = inputBuffer.duplicate().order(ByteOrder.nativeOrder()) + + when (inputAudioFormat.encoding) { + C.ENCODING_PCM_FLOAT -> { + val fb = dup.asFloatBuffer() + val n = fb.remaining() + if (n <= 0) return + if (scratch.size < n) scratch = FloatArray(n) + fb.get(scratch, 0, n) + ScopeBridge.nativePushFramesPostEq(scratch, n / channels, channels) + } + C.ENCODING_PCM_16BIT -> { + val sb = dup.asShortBuffer() + val n = sb.remaining() + if (n <= 0) return + if (scratch.size < n) scratch = FloatArray(n) + var i = 0 + while (i < n) { + scratch[i] = sb.get(i) / 32768f + i++ + } + ScopeBridge.nativePushFramesPostEq(scratch, n / channels, channels) + } + else -> { /* unsupported PCM encoding — forward only */ } + } + } +} diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactory.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactory.kt index 09acee3..5e2f480 100644 --- a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactory.kt +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/ScopeRenderersFactory.kt @@ -7,10 +7,14 @@ import com.google.android.exoplayer2.audio.AudioSink import com.google.android.exoplayer2.audio.DefaultAudioSink /** - * A DefaultRenderersFactory whose audio sink runs our pre-EQ PCM tap as the - * first (and, for M3, only) AudioProcessor. Float-output / playback-param - * capabilities are preserved by forwarding the flags. M4 will prepend the EQ - * AudioProcessor (and add a second post-EQ tap) to this same chain. + * A DefaultRenderersFactory whose audio sink runs the M4 processing chain. + * Order matters: + * 1. NormalizationGainProcessor — per-track gain (before the taps, so the + * scopes see normalized levels). + * 2. ScopeTapAudioProcessor — the pre-EQ tap (post-normalization) → scope ring #1. + * 3. EqAudioProcessor — preamp + parametric biquad chain. + * 4. PostEqTapAudioProcessor — post-EQ tap → scope ring #2 (EQ screen overlay). + * Float-output / playback-param capabilities are preserved by forwarding the flags. */ fun buildScopeRenderersFactory(context: Context): DefaultRenderersFactory = object : DefaultRenderersFactory(context) { @@ -23,6 +27,13 @@ fun buildScopeRenderersFactory(context: Context): DefaultRenderersFactory = DefaultAudioSink.Builder(context) .setEnableFloatOutput(enableFloatOutput) .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) - .setAudioProcessors(arrayOf(ScopeTapAudioProcessor())) + .setAudioProcessors( + arrayOf( + NormalizationGainProcessor(), + ScopeTapAudioProcessor(), + EqAudioProcessor(), + PostEqTapAudioProcessor() + ) + ) .build() }