mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
m4
This commit is contained in:
@@ -46,7 +46,11 @@ example
|
|||||||
modules/*/android/build/
|
modules/*/android/build/
|
||||||
modules/*/android/.gradle/
|
modules/*/android/.gradle/
|
||||||
modules/*/android/.cxx/
|
modules/*/android/.cxx/
|
||||||
|
|
||||||
|
# vendored kotlin-audio fork — built from source by the root Gradle build
|
||||||
vendor/kotlinaudio/kotlin-audio/build/
|
vendor/kotlinaudio/kotlin-audio/build/
|
||||||
|
vendor/kotlinaudio/kotlin-audio/.gradle/
|
||||||
|
vendor/kotlinaudio/kotlin-audio/.cxx/
|
||||||
|
|
||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
DESIGN.md
|
DESIGN.md
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ android {
|
|||||||
abortOnError false
|
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'
|
||||||
|
}
|
||||||
|
|||||||
+371
-11
@@ -12,9 +12,19 @@ import android.media.MediaMetadataRetriever
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.DocumentsContract
|
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.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.max
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.math.tan
|
||||||
import expo.modules.kotlin.exception.Exceptions
|
import expo.modules.kotlin.exception.Exceptions
|
||||||
import expo.modules.kotlin.functions.Coroutine
|
import expo.modules.kotlin.functions.Coroutine
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
@@ -38,6 +48,21 @@ class FileRequest : Record {
|
|||||||
@Field val coverUri: String? = null
|
@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() {
|
class AstraLibraryScannerModule : Module() {
|
||||||
private val artworkThumbSize = 128
|
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.
|
// run lazily per track on the JS side; results are cached in SQLite there.
|
||||||
AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int ->
|
AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int ->
|
||||||
waveformSemaphore.withPermit {
|
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") {
|
Function("getArtworkDirPath") {
|
||||||
artworkDir().absolutePath
|
artworkDir().absolutePath
|
||||||
}
|
}
|
||||||
@@ -127,6 +167,100 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
private fun artworkThumbDir(): File =
|
private fun artworkThumbDir(): File =
|
||||||
File(requireContext().filesDir, "artwork-thumbs").apply { if (!exists()) mkdirs() }
|
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
|
// Directory walk
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -303,11 +437,12 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
// Waveform peaks (offline RMS bins)
|
// Waveform peaks (offline RMS bins)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Decodes the whole file to PCM and accumulates RMS energy per bin (mirrors
|
// One whole-file PCM decode -> per-bin RMS waveform peaks (normalized [0,1]) for the
|
||||||
// desktop waveformExtractor.extractWaveformPeaks), normalized to [0,1]. Returns
|
// seek bar. Returns empty peaks on any failure (caller falls back to a flat seek
|
||||||
// an empty array on any failure (caller falls back to a flat seek bar).
|
// bar). Loudness is measured separately by measureLoudness.
|
||||||
private fun extractWaveform(uriStr: String, bins: Int): FloatArray {
|
private fun decodeAndAnalyze(uriStr: String, bins: Int): AudioAnalysis {
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
|
val result = AudioAnalysis()
|
||||||
val uri = Uri.parse(uriStr)
|
val uri = Uri.parse(uriStr)
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
var codec: MediaCodec? = null
|
var codec: MediaCodec? = null
|
||||||
@@ -322,7 +457,7 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
trackFormat = f; trackIndex = i; break
|
trackFormat = f; trackIndex = i; break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val format = trackFormat ?: return FloatArray(0)
|
val format = trackFormat ?: return result
|
||||||
extractor.selectTrack(trackIndex)
|
extractor.selectTrack(trackIndex)
|
||||||
|
|
||||||
val sampleRate =
|
val sampleRate =
|
||||||
@@ -370,7 +505,7 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
out.position(info.offset)
|
out.position(info.offset)
|
||||||
out.limit(info.offset + info.size)
|
out.limit(info.offset + info.size)
|
||||||
out.order(ByteOrder.nativeOrder())
|
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)
|
codec.releaseOutputBuffer(outIndex, false)
|
||||||
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||||
@@ -394,9 +529,10 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
if (globalMax > 0) {
|
if (globalMax > 0) {
|
||||||
for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat()
|
for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat()
|
||||||
}
|
}
|
||||||
return peaks
|
result.peaks = peaks
|
||||||
|
return result
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
return FloatArray(0)
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
try { codec?.stop() } catch (_: Throwable) {}
|
try { codec?.stop() } catch (_: Throwable) {}
|
||||||
try { codec?.release() } 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.
|
// 16-bit (default) and float PCM. Returns the updated running frame index.
|
||||||
private fun accumulate(
|
private fun accumulateAnalyze(
|
||||||
out: java.nio.ByteBuffer,
|
out: java.nio.ByteBuffer,
|
||||||
pcmFloat: Boolean,
|
pcmFloat: Boolean,
|
||||||
channelCount: Int,
|
channelCount: Int,
|
||||||
@@ -451,6 +706,111 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
return frame
|
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<Double>()
|
||||||
|
|
||||||
|
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? {
|
private fun readBitsPerSample(format: MediaFormat): Int? {
|
||||||
// The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs
|
// The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs
|
||||||
// may expose a PCM encoding instead. Both are best-effort.
|
// may expose a PCM encoding instead. Both are best-effort.
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export interface ExtractedMetadata {
|
|||||||
artworkHash?: string | null;
|
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 {
|
export interface ScanProgressEvent {
|
||||||
phase: 'discovering';
|
phase: 'discovering';
|
||||||
found: number;
|
found: number;
|
||||||
@@ -60,6 +68,18 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
|
|||||||
* the waveform seek bar. Whole-file decode (heavy); returns [] on failure.
|
* the waveform seek bar. Whole-file decode (heavy); returns [] on failure.
|
||||||
*/
|
*/
|
||||||
extractWaveform(uri: string, bins: number): Promise<number[]>;
|
extractWaveform(uri: string, bins: number): Promise<number[]>;
|
||||||
|
/**
|
||||||
|
* 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<ReplayGainTags>;
|
||||||
getArtworkDirPath(): string;
|
getArtworkDirPath(): string;
|
||||||
getArtworkThumbDirPath(): string;
|
getArtworkThumbDirPath(): string;
|
||||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
||||||
|
|||||||
@@ -29,5 +29,52 @@ class AstraScopeModule : Module() {
|
|||||||
Function("getOscilloscopeFrame") { out: Float32Array ->
|
Function("getOscilloscopeFrame") { out: Float32Array ->
|
||||||
ScopeBridge.nativeFillOscilloscope(out.toDirectBuffer(), out.length)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<String, Float>()
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ object ScopeBridge {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var active: Boolean = false
|
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. */
|
/** Audio thread. Tell the analyzer the stream's sample rate / channels. */
|
||||||
external fun nativeConfigure(sampleRate: Int, channelCount: Int)
|
external fun nativeConfigure(sampleRate: Int, channelCount: Int)
|
||||||
|
|
||||||
@@ -41,4 +49,13 @@ object ScopeBridge {
|
|||||||
* writes straight into JS memory.
|
* writes straight into JS memory.
|
||||||
*/
|
*/
|
||||||
external fun nativeFillOscilloscope(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,38 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillOscilloscope(
|
|||||||
return static_cast<jint>(n);
|
return static_cast<jint>(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<float*>(
|
||||||
|
env->GetPrimitiveArrayCritical(frames, nullptr));
|
||||||
|
if (data == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
driver().pushInterleavedPostEq(data, static_cast<size_t>(frameCount),
|
||||||
|
static_cast<int>(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<float*>(env->GetDirectBufferAddress(buffer));
|
||||||
|
if (dst == nullptr) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const size_t n = driver().fillSpectrumPostEq(dst, static_cast<size_t>(capacityFloats));
|
||||||
|
return static_cast<jint>(n);
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
@@ -184,10 +184,70 @@ class ScopeDriver {
|
|||||||
return count;
|
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<float>(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<float>(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<size_t>(sr) : static_cast<size_t>(48000);
|
||||||
|
const size_t delaySamples = scopeOutputDelaySamples(sampleRate);
|
||||||
|
const size_t readHead = w > delaySamples ? w - delaySamples : 0;
|
||||||
|
|
||||||
|
const std::vector<float>* 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; }
|
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
spectrum_.reset();
|
spectrum_.reset();
|
||||||
|
postEqSpectrum_.reset();
|
||||||
osc_.reset();
|
osc_.reset();
|
||||||
oscReadPos_ = writePos_.load(std::memory_order_acquire);
|
oscReadPos_ = writePos_.load(std::memory_order_acquire);
|
||||||
oscSamplesSeen_ = 0;
|
oscSamplesSeen_ = 0;
|
||||||
@@ -196,9 +256,11 @@ class ScopeDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ScopeDriver() : spectrum_(kFftSize) {
|
ScopeDriver() : spectrum_(kFftSize), postEqSpectrum_(kFftSize) {
|
||||||
spectrum_.setSmoothing(0.92f);
|
spectrum_.setSmoothing(0.92f);
|
||||||
|
postEqSpectrum_.setSmoothing(0.92f);
|
||||||
ring_.assign(kSize, 0.0f);
|
ring_.assign(kSize, 0.0f);
|
||||||
|
postEqRing_.assign(kSize, 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
static constexpr size_t kFftSize = 2048; // -> 1024 dB bins
|
static constexpr size_t kFftSize = 2048; // -> 1024 dB bins
|
||||||
@@ -277,6 +339,13 @@ class ScopeDriver {
|
|||||||
Visualizer::Spectrum spectrum_;
|
Visualizer::Spectrum spectrum_;
|
||||||
int appliedSampleRate_{0};
|
int appliedSampleRate_{0};
|
||||||
|
|
||||||
|
// Post-EQ source (M4) — second SPSC ring + spectrum-only analyzer.
|
||||||
|
std::vector<float> postEqRing_;
|
||||||
|
std::atomic<size_t> postEqWritePos_{0};
|
||||||
|
std::vector<float> postEqScratch_;
|
||||||
|
Visualizer::Spectrum postEqSpectrum_;
|
||||||
|
int postEqAppliedSampleRate_{0};
|
||||||
|
|
||||||
Visualizer::Oscilloscope osc_;
|
Visualizer::Oscilloscope osc_;
|
||||||
size_t oscReadPos_{0};
|
size_t oscReadPos_{0};
|
||||||
size_t oscSamplesSeen_{0};
|
size_t oscSamplesSeen_{0};
|
||||||
|
|||||||
@@ -25,6 +25,37 @@ declare class AstraScopeModuleType extends NativeModule {
|
|||||||
* ~[-1, 1]. Returns the number of points written (0 before warmup).
|
* ~[-1, 1]. Returns the number of points written (0 before warmup).
|
||||||
*/
|
*/
|
||||||
getOscilloscopeFrame(out: Float32Array): number;
|
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<AstraScopeModuleType>('AstraScope');
|
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
||||||
|
|||||||
+355
-50
@@ -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 { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
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 { colors, radius, spacing } from '@/theme';
|
||||||
import { useEQStore } from '@/stores/eqStore';
|
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 {
|
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
|
||||||
return hz >= 1000 ? `${hz / 1000}k` : `${hz}`;
|
|
||||||
}
|
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||||
|
|
||||||
export default function EQScreen() {
|
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<SheetKind>('none');
|
||||||
|
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(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 (
|
return (
|
||||||
<Screen>
|
<Screen padded={false}>
|
||||||
<Text variant="title" style={styles.heading}>
|
<View style={styles.header}>
|
||||||
Equalizer
|
<Text variant="heading">Equalizer</Text>
|
||||||
</Text>
|
<View style={styles.headerActions}>
|
||||||
<Text variant="body" color={colors.textSecondary} style={styles.note}>
|
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
|
||||||
The band model is in place. The Media3 biquad chain that makes these
|
<Ionicons name="save-outline" size={20} color={colors.textSecondary} />
|
||||||
sliders live arrives in M4.
|
</Pressable>
|
||||||
</Text>
|
<Pressable style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||||
<View style={styles.bands}>
|
</Pressable>
|
||||||
{bands.map((band) => (
|
</View>
|
||||||
<View key={band.id} style={styles.band}>
|
|
||||||
<View style={styles.track}>
|
|
||||||
<View style={styles.knob} />
|
|
||||||
</View>
|
|
||||||
<Text variant="caption" style={styles.freq}>
|
|
||||||
{formatFreq(band.frequency)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
|
||||||
|
<Text variant="body" color={colors.textPrimary}>
|
||||||
|
{presetName}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.graphWrap}>
|
||||||
|
<EQGraph
|
||||||
|
bands={eq.bands}
|
||||||
|
activeBandId={eq.activeBandId}
|
||||||
|
enabled={eq.enabled}
|
||||||
|
spectrumActive={scopeActive && focused}
|
||||||
|
onSelectBand={eq.selectBand}
|
||||||
|
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<BandStrip
|
||||||
|
bands={eq.bands}
|
||||||
|
activeBandId={eq.activeBandId}
|
||||||
|
canAdd={eq.bands.length < EQ_MAX_BANDS}
|
||||||
|
onSelect={eq.selectBand}
|
||||||
|
onAdd={() => eq.addBand()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<BandDetailPanel
|
||||||
|
band={activeBand}
|
||||||
|
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
|
||||||
|
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
|
||||||
|
onEditType={() => setSheet('type')}
|
||||||
|
onEditValue={setEditingValue}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.bottomBar}>
|
||||||
|
<View style={styles.preamp}>
|
||||||
|
<EQSlider
|
||||||
|
label="Preamp"
|
||||||
|
value={eq.preamp}
|
||||||
|
min={EQ_MIN_PREAMP_DB}
|
||||||
|
max={EQ_MAX_PREAMP_DB}
|
||||||
|
format={(v) => `${formatGain(v)} dB`}
|
||||||
|
onChange={eq.setPreamp}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
|
||||||
|
onPress={eq.toggleEnabled}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="power"
|
||||||
|
size={16}
|
||||||
|
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
|
||||||
|
{eq.enabled ? 'EQ on' : 'EQ off'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{sheet === 'preset' ? (
|
||||||
|
<PresetSheet
|
||||||
|
presets={eq.presets}
|
||||||
|
activePresetId={eq.activePresetId}
|
||||||
|
onApply={eq.applyPreset}
|
||||||
|
onDelete={eq.deleteCustomPreset}
|
||||||
|
onSaveNew={() => setSheet('save')}
|
||||||
|
onClose={closeSheet}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sheet === 'save' ? (
|
||||||
|
<SavePresetSheet
|
||||||
|
defaultName={defaultPresetName}
|
||||||
|
onSave={(name) => eq.saveCustomPreset(name)}
|
||||||
|
onClose={closeSheet}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sheet === 'overflow' ? (
|
||||||
|
<EqSheet onClose={closeSheet}>
|
||||||
|
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
|
||||||
|
{eq.bands.length > 1 && activeBand ? (
|
||||||
|
<EqSheetItem
|
||||||
|
label={`Remove band ${activeBandNumber}`}
|
||||||
|
icon="remove-circle-outline"
|
||||||
|
onPress={() => {
|
||||||
|
eq.removeBand(activeBand.id);
|
||||||
|
closeSheet();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<EqSheetItem
|
||||||
|
label="Reset to Flat"
|
||||||
|
icon="refresh-outline"
|
||||||
|
destructive
|
||||||
|
onPress={() => {
|
||||||
|
eq.resetToFlat();
|
||||||
|
closeSheet();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EqSheet>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sheet === 'type' && activeBand ? (
|
||||||
|
<EqSheet onClose={closeSheet}>
|
||||||
|
<Text variant="heading" style={styles.sheetTitle}>
|
||||||
|
Filter type
|
||||||
|
</Text>
|
||||||
|
{BAND_TYPES.map((type) => (
|
||||||
|
<EqSheetItem
|
||||||
|
key={type}
|
||||||
|
label={BAND_TYPE_LABEL[type]}
|
||||||
|
selected={type === activeBand.type}
|
||||||
|
onPress={() => {
|
||||||
|
eq.updateBand(activeBand.id, { type });
|
||||||
|
closeSheet();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</EqSheet>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{valueEditConfig && activeBand && editingValue ? (
|
||||||
|
<EQValueEditSheet
|
||||||
|
title={valueEditConfig.title}
|
||||||
|
initialValue={valueEditConfig.initialValue}
|
||||||
|
unit={valueEditConfig.unit}
|
||||||
|
rangeLabel={valueEditConfig.rangeLabel}
|
||||||
|
placeholder={valueEditConfig.placeholder}
|
||||||
|
keyboardType={valueEditConfig.keyboardType}
|
||||||
|
parseValue={valueEditConfig.parseValue}
|
||||||
|
onApply={(value) => eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))}
|
||||||
|
onClose={() => setEditingValue(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<EQBand> {
|
||||||
|
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({
|
const styles = StyleSheet.create({
|
||||||
heading: {
|
header: {
|
||||||
marginTop: spacing.xl,
|
|
||||||
},
|
|
||||||
note: {
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
marginBottom: spacing.xxl,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
bands: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
},
|
|
||||||
band: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
track: {
|
iconButton: {
|
||||||
width: 4,
|
width: 40,
|
||||||
height: 140,
|
height: 40,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.glassBorder,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
},
|
},
|
||||||
knob: {
|
presetRow: {
|
||||||
width: 14,
|
flexDirection: 'row',
|
||||||
height: 14,
|
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,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: colors.accent,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
},
|
},
|
||||||
freq: {
|
eqToggleOn: {
|
||||||
color: colors.textSecondary,
|
borderColor: colors.accent,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
},
|
||||||
|
sheetTitle: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+185
-41
@@ -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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { EQSlider } from '@/components/eq/EQSlider';
|
||||||
import { colors, radius, spacing } from '@/theme';
|
import { colors, radius, spacing } from '@/theme';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||||
|
import type { ReplayGainMode } from '@/audio/normalization';
|
||||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||||
|
|
||||||
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
|
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 (
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<View style={styles.toggleText}>
|
||||||
|
<Text variant="body">{title}</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||||
|
thumbColor={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||||
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
|
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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<Text variant="title" style={styles.heading}>
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||||
Settings
|
<Text variant="title" style={styles.heading}>
|
||||||
</Text>
|
Settings
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||||
LIBRARY
|
AUDIO
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="body" style={styles.settingTitle}>
|
<View style={styles.card}>
|
||||||
Artist grouping
|
<ToggleRow
|
||||||
</Text>
|
title="Loudness normalization"
|
||||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
|
||||||
How tracks are organized into artists in the library.
|
value={normalizationEnabled}
|
||||||
</Text>
|
onValueChange={(v) => void setNormalizationEnabled(v)}
|
||||||
|
/>
|
||||||
|
{normalizationEnabled ? (
|
||||||
|
<View style={styles.indent}>
|
||||||
|
<EQSlider
|
||||||
|
label="Target"
|
||||||
|
value={normalizationTargetLufs}
|
||||||
|
min={-30}
|
||||||
|
max={-5}
|
||||||
|
format={(v) => `${Math.round(v)} LUFS`}
|
||||||
|
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.options}>
|
<View style={[styles.card, styles.cardSpacing]}>
|
||||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
<ToggleRow
|
||||||
const selected = option.mode === groupingMode;
|
title="ReplayGain"
|
||||||
return (
|
description="Use ReplayGain tags when present; falls back to the measured loudness above."
|
||||||
<Pressable
|
value={replayGainEnabled}
|
||||||
key={option.mode}
|
onValueChange={(v) => void setReplayGainEnabled(v)}
|
||||||
style={[styles.option, selected && styles.optionSelected]}
|
/>
|
||||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
{replayGainEnabled ? (
|
||||||
accessibilityRole="radio"
|
<View style={styles.modeRow}>
|
||||||
accessibilityState={{ selected }}
|
{REPLAYGAIN_MODES.map((m) => {
|
||||||
>
|
const selected = m.mode === replayGainMode;
|
||||||
<View style={styles.optionText}>
|
return (
|
||||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
<Pressable
|
||||||
{option.title}
|
key={m.mode}
|
||||||
</Text>
|
style={[styles.modePill, selected && styles.modePillSelected]}
|
||||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
onPress={() => void setReplayGainMode(m.mode)}
|
||||||
{option.description}
|
>
|
||||||
</Text>
|
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
|
||||||
</View>
|
{m.label}
|
||||||
{selected ? (
|
</Text>
|
||||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
</Pressable>
|
||||||
) : (
|
);
|
||||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
})}
|
||||||
)}
|
</View>
|
||||||
</Pressable>
|
) : null}
|
||||||
);
|
</View>
|
||||||
})}
|
|
||||||
</View>
|
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
||||||
|
LIBRARY
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" style={styles.settingTitle}>
|
||||||
|
Artist grouping
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||||
|
How tracks are organized into artists in the library.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.options}>
|
||||||
|
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||||
|
const selected = option.mode === groupingMode;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.mode}
|
||||||
|
style={[styles.option, selected && styles.optionSelected]}
|
||||||
|
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
>
|
||||||
|
<View style={styles.optionText}>
|
||||||
|
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||||
|
{option.title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{selected ? (
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
content: {
|
||||||
|
paddingBottom: spacing.xxl,
|
||||||
|
},
|
||||||
heading: {
|
heading: {
|
||||||
marginTop: spacing.xl,
|
marginTop: spacing.xl,
|
||||||
marginBottom: spacing.xxl,
|
marginBottom: spacing.xxl,
|
||||||
@@ -80,6 +183,47 @@ const styles = StyleSheet.create({
|
|||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: spacing.sm,
|
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: {
|
settingTitle: {
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
|
|||||||
+20
-1
@@ -18,6 +18,9 @@ import {
|
|||||||
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
||||||
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import { useEQStore } from '@/stores/eqStore';
|
||||||
|
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||||
|
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
||||||
import { colors } from '@/theme';
|
import { colors } from '@/theme';
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -34,6 +37,12 @@ function ScopeLifecycle() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pushes per-track normalization gain to native on track/settings change. */
|
||||||
|
function NormalizationSync() {
|
||||||
|
useNormalizationSync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Inter_400Regular,
|
Inter_400Regular,
|
||||||
@@ -51,12 +60,21 @@ export default function RootLayout() {
|
|||||||
}, [fontsLoaded]);
|
}, [fontsLoaded]);
|
||||||
|
|
||||||
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
// 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(() => {
|
useEffect(() => {
|
||||||
useLibraryStore
|
useLibraryStore
|
||||||
.getState()
|
.getState()
|
||||||
.initialize()
|
.initialize()
|
||||||
.catch((err) => console.error('[library] init failed', err));
|
.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;
|
if (!fontsLoaded) return null;
|
||||||
@@ -67,6 +85,7 @@ export default function RootLayout() {
|
|||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<PlaybackSync />
|
<PlaybackSync />
|
||||||
<ScopeLifecycle />
|
<ScopeLifecycle />
|
||||||
|
<NormalizationSync />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
|||||||
+63
-40
@@ -16,12 +16,14 @@ import Animated, {
|
|||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { FormatBadges } from '@/components/FormatBadge';
|
import { FormatBadges } from '@/components/FormatBadge';
|
||||||
|
import { MarqueeText } from '@/components/MarqueeText';
|
||||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||||
import { Visualizer } from '@/components/Visualizer';
|
import { Visualizer } from '@/components/Visualizer';
|
||||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||||
import { QueueTray } from '@/components/queue/QueueTray';
|
import { QueueTray } from '@/components/queue/QueueTray';
|
||||||
import { colors, radius, spacing } from '@/theme';
|
import { colors, radius, spacing } from '@/theme';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
|
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||||
@@ -177,6 +179,7 @@ export default function NowPlayingScreen() {
|
|||||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||||
|
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||||
const libraryTracks = useLibraryStore((s) => s.tracks);
|
const libraryTracks = useLibraryStore((s) => s.tracks);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
@@ -194,11 +197,15 @@ export default function NowPlayingScreen() {
|
|||||||
const source = track?.album?.trim() ? track.album : 'Library';
|
const source = track?.album?.trim() ? track.album : 'Library';
|
||||||
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
|
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
|
||||||
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
|
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
|
||||||
const artistName = track?.artist.trim() ?? '';
|
|
||||||
const libraryTrack = useMemo(
|
const libraryTrack = useMemo(
|
||||||
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
||||||
[libraryTracks, track]
|
[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 albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
|
||||||
|
|
||||||
const navigateToArtist = () => {
|
const navigateToArtist = () => {
|
||||||
@@ -454,9 +461,13 @@ export default function NowPlayingScreen() {
|
|||||||
<View style={styles.playerControls}>
|
<View style={styles.playerControls}>
|
||||||
<View style={styles.trackInfo}>
|
<View style={styles.trackInfo}>
|
||||||
<View style={styles.trackTextStack}>
|
<View style={styles.trackTextStack}>
|
||||||
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
|
<MarqueeText
|
||||||
|
variant="heading"
|
||||||
|
containerStyle={styles.trackTitle}
|
||||||
|
style={styles.trackTitleText}
|
||||||
|
>
|
||||||
{track.title}
|
{track.title}
|
||||||
</Text>
|
</MarqueeText>
|
||||||
<View style={styles.trackMetaRow}>
|
<View style={styles.trackMetaRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={navigateToArtist}
|
onPress={navigateToArtist}
|
||||||
@@ -465,13 +476,10 @@ export default function NowPlayingScreen() {
|
|||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={`View artist ${track.artist}`}
|
accessibilityLabel={`View artist ${track.artist}`}
|
||||||
>
|
>
|
||||||
<Text variant="body" numberOfLines={1} style={styles.artist}>
|
<MarqueeText variant="body" style={styles.artist}>
|
||||||
{track.artist}
|
{track.artist}
|
||||||
</Text>
|
</MarqueeText>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.badges}>
|
|
||||||
<FormatBadges track={track} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -566,31 +574,36 @@ export default function NowPlayingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.subRow}>
|
<View style={styles.subRow}>
|
||||||
<Pressable
|
<View style={styles.subBadges}>
|
||||||
hitSlop={10}
|
<FormatBadges track={track} wrap={false} />
|
||||||
style={styles.subBtn}
|
</View>
|
||||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
<View style={styles.subActions}>
|
||||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
<Pressable
|
||||||
accessibilityState={{ selected: scopeStageVisible }}
|
hitSlop={10}
|
||||||
>
|
style={styles.subBtn}
|
||||||
<MaterialCommunityIcons
|
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||||
name="sine-wave"
|
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||||
size={SUB_ICON_SIZE + 2}
|
accessibilityState={{ selected: scopeStageVisible }}
|
||||||
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
>
|
||||||
/>
|
<MaterialCommunityIcons
|
||||||
</Pressable>
|
name="sine-wave"
|
||||||
<Pressable
|
size={SUB_ICON_SIZE + 2}
|
||||||
hitSlop={10}
|
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||||
style={styles.subBtn}
|
/>
|
||||||
onPress={() => setQueueOpen(true)}
|
</Pressable>
|
||||||
accessibilityLabel="Queue"
|
<Pressable
|
||||||
>
|
hitSlop={10}
|
||||||
<Ionicons
|
style={styles.subBtn}
|
||||||
name="list-outline"
|
onPress={() => setQueueOpen(true)}
|
||||||
size={SUB_ICON_SIZE + 2}
|
accessibilityLabel="Queue"
|
||||||
color={colors.textTertiary}
|
>
|
||||||
/>
|
<Ionicons
|
||||||
</Pressable>
|
name="list-outline"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -778,6 +791,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
trackTitle: {
|
trackTitle: {
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
|
},
|
||||||
|
trackTitleText: {
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
},
|
},
|
||||||
inlineActionBtn: {
|
inlineActionBtn: {
|
||||||
@@ -789,13 +804,13 @@ const styles = StyleSheet.create({
|
|||||||
trackMetaRow: {
|
trackMetaRow: {
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'nowrap',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
artistButton: {
|
artistButton: {
|
||||||
flexShrink: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
centered: {
|
centered: {
|
||||||
@@ -804,9 +819,6 @@ const styles = StyleSheet.create({
|
|||||||
artist: {
|
artist: {
|
||||||
color: colors.accentText,
|
color: colors.accentText,
|
||||||
},
|
},
|
||||||
badges: {
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
spacer: {
|
spacer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: MIN_FLOATING_SPACE,
|
minHeight: MIN_FLOATING_SPACE,
|
||||||
@@ -843,11 +855,22 @@ const styles = StyleSheet.create({
|
|||||||
subRow: {
|
subRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'space-between',
|
||||||
gap: spacing.lg,
|
gap: spacing.md,
|
||||||
marginTop: SUB_TOP_MARGIN,
|
marginTop: SUB_TOP_MARGIN,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
},
|
},
|
||||||
|
subBadges: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
subActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
subBtn: {
|
subBtn: {
|
||||||
width: SUB_BUTTON_SIZE,
|
width: SUB_BUTTON_SIZE,
|
||||||
height: SUB_BUTTON_SIZE,
|
height: SUB_BUTTON_SIZE,
|
||||||
|
|||||||
@@ -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<string, EQBandType> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+266
@@ -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<EQBandType, number> = {
|
||||||
|
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<T extends EQBand>(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<EQPreset, 'name' | 'preamp' | 'bands'>): {
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
preamp: number;
|
||||||
|
bands: Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>[];
|
||||||
|
} {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EQBand, 'id' | 'enabled'> & { 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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, Promise<LoudnessFacts>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<LoudnessFacts> {
|
||||||
|
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<LoudnessFacts> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<typeof setTimeout> | 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();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -20,8 +20,10 @@ export function Badge({ label }: { label: string }) {
|
|||||||
*/
|
*/
|
||||||
export function FormatBadges({
|
export function FormatBadges({
|
||||||
track,
|
track,
|
||||||
|
wrap = true,
|
||||||
}: {
|
}: {
|
||||||
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
|
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
|
||||||
|
wrap?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
if (track.format) labels.push(track.format.toUpperCase());
|
if (track.format) labels.push(track.format.toUpperCase());
|
||||||
@@ -31,7 +33,7 @@ export function FormatBadges({
|
|||||||
if (labels.length === 0) return null;
|
if (labels.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={[styles.row, !wrap && styles.rowNoWrap]}>
|
||||||
{labels.map((label) => (
|
{labels.map((label) => (
|
||||||
<Badge key={label} label={label} />
|
<Badge key={label} label={label} />
|
||||||
))}
|
))}
|
||||||
@@ -45,6 +47,9 @@ const styles = StyleSheet.create({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
|
rowNoWrap: {
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
},
|
||||||
badge: {
|
badge: {
|
||||||
backgroundColor: colors.glassBg,
|
backgroundColor: colors.glassBg,
|
||||||
borderColor: colors.glassBorder,
|
borderColor: colors.glassBorder,
|
||||||
|
|||||||
@@ -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<TextStyle>;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
|
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 (
|
||||||
|
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.content, textWidth > 0 ? { width: textWidth } : null, animatedStyle]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant={variant}
|
||||||
|
color={color}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="clip"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Text
|
||||||
|
variant={variant}
|
||||||
|
color={color}
|
||||||
|
numberOfLines={1}
|
||||||
|
onTextLayout={handleTextLayout}
|
||||||
|
style={[styles.measure, style]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
measure: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: MEASURE_WIDTH,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MarqueeText;
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
type SkPicture,
|
type SkPicture,
|
||||||
} from '@shopify/react-native-skia';
|
} from '@shopify/react-native-skia';
|
||||||
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
|
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
|
||||||
|
import { useScopeStore } from '@/scope/scopeStore';
|
||||||
|
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
|
||||||
import { colors } from '@/theme';
|
import { colors } from '@/theme';
|
||||||
|
|
||||||
interface OscilloscopeWaveProps {
|
interface OscilloscopeWaveProps {
|
||||||
@@ -25,7 +27,6 @@ type SkiaViewApiShape = {
|
|||||||
requestRedraw: (nativeId: number) => void;
|
requestRedraw: (nativeId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISUAL_GAIN = 1.8;
|
|
||||||
const values = new Float32Array(OSCILLOSCOPE_POINTS);
|
const values = new Float32Array(OSCILLOSCOPE_POINTS);
|
||||||
|
|
||||||
function skiaViewApi(): SkiaViewApiShape | null {
|
function skiaViewApi(): SkiaViewApiShape | null {
|
||||||
@@ -58,7 +59,8 @@ function buildPicture(
|
|||||||
height: number,
|
height: number,
|
||||||
color: string,
|
color: string,
|
||||||
lineWidth: number,
|
lineWidth: number,
|
||||||
glow: boolean
|
glow: boolean,
|
||||||
|
gain: number
|
||||||
): SkPicture {
|
): SkPicture {
|
||||||
const recorder = Skia.PictureRecorder();
|
const recorder = Skia.PictureRecorder();
|
||||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
||||||
@@ -70,7 +72,9 @@ function buildPicture(
|
|||||||
const amp = mid - lineWidth;
|
const amp = mid - lineWidth;
|
||||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||||
const yAt = (i: number) => {
|
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;
|
if (v < -1) v = -1;
|
||||||
else if (v > 1) v = 1;
|
else if (v > 1) v = 1;
|
||||||
return mid - v * amp;
|
return mid - v * amp;
|
||||||
@@ -94,6 +98,10 @@ function buildPicture(
|
|||||||
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
|
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
|
||||||
* a frame loop pulls native scope data and draws directly into a canvas-like
|
* a frame loop pulls native scope data and draws directly into a canvas-like
|
||||||
* surface instead of routing each frame through React reconciliation.
|
* 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({
|
export function OscilloscopeWave({
|
||||||
active,
|
active,
|
||||||
@@ -106,7 +114,17 @@ export function OscilloscopeWave({
|
|||||||
}: OscilloscopeWaveProps) {
|
}: OscilloscopeWaveProps) {
|
||||||
const viewRef = useRef<SkiaPictureView | null>(null);
|
const viewRef = useRef<SkiaPictureView | null>(null);
|
||||||
const initialPicture = useMemo(
|
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]
|
[color, glow, height, lineWidth, width]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,7 +137,8 @@ export function OscilloscopeWave({
|
|||||||
let raf = 0;
|
let raf = 0;
|
||||||
|
|
||||||
const draw = (sampleCount: number) => {
|
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.setJsiProperty(view.nativeId, 'picture', picture);
|
||||||
api.requestRedraw(view.nativeId);
|
api.requestRedraw(view.nativeId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface SpectrumCurveProps {
|
|||||||
height: number;
|
height: number;
|
||||||
/** Pull native spectrum frames while active, bypassing React per-frame state. */
|
/** Pull native spectrum frames while active, bypassing React per-frame state. */
|
||||||
active?: boolean;
|
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. */
|
/** Number of render points when active. Defaults to one point per rendered pixel. */
|
||||||
pointCount?: number;
|
pointCount?: number;
|
||||||
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
|
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
|
||||||
@@ -292,6 +294,7 @@ export function SpectrumCurve({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
active = false,
|
active = false,
|
||||||
|
source = 'pre',
|
||||||
pointCount,
|
pointCount,
|
||||||
frameMs = MINI_FRAME_MS,
|
frameMs = MINI_FRAME_MS,
|
||||||
analysisFrameMs,
|
analysisFrameMs,
|
||||||
@@ -391,7 +394,11 @@ export function SpectrumCurve({
|
|||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
|
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
|
||||||
lastAnalysis = t;
|
lastAnalysis = t;
|
||||||
if (AstraScope.getSpectrumFrame(spectrumBins) > 0) {
|
const got =
|
||||||
|
source === 'post'
|
||||||
|
? AstraScope.getSpectrumFramePostEq(spectrumBins)
|
||||||
|
: AstraScope.getSpectrumFrame(spectrumBins);
|
||||||
|
if (got > 0) {
|
||||||
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
|
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
|
||||||
hasNewFrame = true;
|
hasNewFrame = true;
|
||||||
}
|
}
|
||||||
@@ -425,6 +432,7 @@ export function SpectrumCurve({
|
|||||||
lineOpacity,
|
lineOpacity,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
resolvedPointCount,
|
resolvedPointCount,
|
||||||
|
source,
|
||||||
tiltDbPerOctave,
|
tiltDbPerOctave,
|
||||||
width,
|
width,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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<EQBand>) => 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 (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text variant="body" color={colors.textSecondary}>
|
||||||
|
Select a band to edit.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPass = isPassEQBandType(band.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text variant="heading">Band {bandNumber}</Text>
|
||||||
|
<Pressable style={styles.typeButton} onPress={onEditType}>
|
||||||
|
<Text variant="label" color={colors.textPrimary}>
|
||||||
|
{BAND_TYPE_LABEL[band.type]}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.toggle}>
|
||||||
|
<Text variant="label">{band.enabled ? 'On' : 'Off'}</Text>
|
||||||
|
<Switch
|
||||||
|
value={band.enabled}
|
||||||
|
onValueChange={(enabled) => onUpdate({ enabled })}
|
||||||
|
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||||
|
thumbColor={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<EQSlider
|
||||||
|
label="Frequency"
|
||||||
|
value={band.frequency}
|
||||||
|
min={EQ_MIN_FREQUENCY}
|
||||||
|
max={EQ_MAX_FREQUENCY}
|
||||||
|
log
|
||||||
|
format={(v) => `${formatFreq(v)} Hz`}
|
||||||
|
onChange={(v) => onUpdate({ frequency: v })}
|
||||||
|
onValuePress={() => onEditValue('frequency')}
|
||||||
|
/>
|
||||||
|
<EQSlider
|
||||||
|
label="Gain"
|
||||||
|
value={isPass ? 0 : band.gain}
|
||||||
|
min={-EQ_MAX_GAIN_DB}
|
||||||
|
max={EQ_MAX_GAIN_DB}
|
||||||
|
format={(v) => `${formatGain(v)} dB`}
|
||||||
|
onChange={(v) => onUpdate({ gain: v })}
|
||||||
|
onValuePress={() => onEditValue('gain')}
|
||||||
|
disabled={isPass}
|
||||||
|
/>
|
||||||
|
<EQSlider
|
||||||
|
label="Q"
|
||||||
|
value={band.Q}
|
||||||
|
min={EQ_MIN_Q}
|
||||||
|
max={EQ_MAX_Q}
|
||||||
|
log
|
||||||
|
format={(v) => v.toFixed(2)}
|
||||||
|
onChange={(v) => onUpdate({ Q: v })}
|
||||||
|
onValuePress={() => onEditValue('Q')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
>
|
||||||
|
{bands.map((band) => {
|
||||||
|
const isActive = band.id === activeBandId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={band.id}
|
||||||
|
onPress={() => onSelect(band.id)}
|
||||||
|
style={[styles.cell, isActive && styles.cellActive]}
|
||||||
|
>
|
||||||
|
<Text variant="caption" style={styles.freq}>
|
||||||
|
{formatFreq(band.frequency)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="label"
|
||||||
|
style={[styles.gain, { color: band.enabled ? gainColor(band.gain) : colors.textTertiary }]}
|
||||||
|
>
|
||||||
|
{formatGain(band.gain)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{canAdd ? (
|
||||||
|
<Pressable onPress={onAdd} style={[styles.cell, styles.addCell]} accessibilityLabel="Add band">
|
||||||
|
<Ionicons name="add" size={22} color={colors.accentText} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.container} onLayout={onLayout}>
|
||||||
|
{width > 0 && height > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Live post-EQ spectrum behind the curve. */}
|
||||||
|
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||||
|
<SpectrumCurve
|
||||||
|
source="post"
|
||||||
|
active={spectrumActive}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
frameMs={0}
|
||||||
|
color={colors.accent}
|
||||||
|
lineOpacity={0.22}
|
||||||
|
fillOpacity={0.5}
|
||||||
|
glow={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||||
|
{/* Grid: ±6 dB lines + dashed 0 dB centerline. */}
|
||||||
|
<Group>
|
||||||
|
<Path
|
||||||
|
path={hLine(0, yPlus6, width)}
|
||||||
|
color={colors.glassBorder}
|
||||||
|
style="stroke"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
path={hLine(0, yMinus6, width)}
|
||||||
|
color={colors.glassBorder}
|
||||||
|
style="stroke"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<Path path={hLine(0, centerY, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
|
||||||
|
<DashPathEffect intervals={[3, 5]} />
|
||||||
|
</Path>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Response curve + soft fill. */}
|
||||||
|
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" />
|
||||||
|
<Path
|
||||||
|
path={linePath}
|
||||||
|
color={curveColor}
|
||||||
|
style="stroke"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeJoin="round"
|
||||||
|
strokeCap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<Group key={band.id}>
|
||||||
|
<Circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={NODE_R}
|
||||||
|
color={isActive ? colors.accent : colors.bgTertiary}
|
||||||
|
opacity={dim ? 0.4 : 1}
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={NODE_R}
|
||||||
|
color={isActive ? colors.accent : colors.glassBorder}
|
||||||
|
style="stroke"
|
||||||
|
strokeWidth={isActive ? 0 : 1.5}
|
||||||
|
opacity={dim ? 0.5 : 1}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<Text
|
||||||
|
key={band.id}
|
||||||
|
variant="caption"
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.nodeLabel,
|
||||||
|
{ left: cx - NODE_R, top: cy - 8 },
|
||||||
|
{ color: isActive ? colors.accentTextStrong : colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* dB labels (right edge). */}
|
||||||
|
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yPlus6 - 6 }]}>
|
||||||
|
+6
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yMinus6 - 6 }]}>
|
||||||
|
-6
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Frequency labels (bottom axis). */}
|
||||||
|
{FREQ_TICKS.map((tick) => (
|
||||||
|
<Text
|
||||||
|
key={tick.label}
|
||||||
|
variant="caption"
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[styles.freqLabel, { left: freqToX(tick.freq, width) - 10 }]}
|
||||||
|
>
|
||||||
|
{tick.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Gesture overlay. */}
|
||||||
|
<View
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
|
onMoveShouldSetResponder={() => true}
|
||||||
|
onResponderTerminationRequest={() => false}
|
||||||
|
onResponderGrant={handleGrant}
|
||||||
|
onResponderMove={handleMove}
|
||||||
|
onResponderRelease={endDrag}
|
||||||
|
onResponderTerminate={endDrag}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
@@ -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 (
|
||||||
|
<View style={[styles.row, disabled && styles.disabled]}>
|
||||||
|
<Text variant="label" style={styles.label}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={styles.touch}
|
||||||
|
onLayout={onLayout}
|
||||||
|
onStartShouldSetResponder={() => !disabled}
|
||||||
|
onMoveShouldSetResponder={() => !disabled}
|
||||||
|
onResponderTerminationRequest={() => false}
|
||||||
|
onResponderGrant={handleGrant}
|
||||||
|
onResponderMove={handleMove}
|
||||||
|
onResponderRelease={() => setActive(false)}
|
||||||
|
onResponderTerminate={() => setActive(false)}
|
||||||
|
accessibilityRole="adjustable"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<View style={[styles.fill, { width: `${fraction * 100}%` }]} />
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.thumb,
|
||||||
|
active && styles.thumbActive,
|
||||||
|
{ left: Math.max(0, fraction * width - THUMB / 2) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{onValuePress && !disabled ? (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.valueButton, pressed && styles.valueButtonPressed]}
|
||||||
|
onPress={onValuePress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Edit ${label}`}
|
||||||
|
>
|
||||||
|
<Text variant="mono" style={styles.value}>
|
||||||
|
{format(value)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Text variant="mono" style={styles.value}>
|
||||||
|
{format(value)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 (
|
||||||
|
<EqSheet onClose={onClose}>
|
||||||
|
<Text variant="heading" style={styles.title}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
style={[styles.input, trimmed.length > 0 && !valid && styles.inputInvalid]}
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
maxLength={16}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={apply}
|
||||||
|
selectionColor={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text variant="label" style={styles.unit}>
|
||||||
|
{unit}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="caption" style={[styles.range, trimmed.length > 0 && !valid && styles.invalidText]}>
|
||||||
|
{valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||||
|
<Text variant="label" color={colors.textSecondary}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, styles.apply, !valid && styles.applyDisabled]}
|
||||||
|
disabled={!valid}
|
||||||
|
onPress={apply}
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
Apply
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</EqSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 && <EqSheet onClose=...>}).
|
||||||
|
*/
|
||||||
|
export function EqSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
pressBehavior="close"
|
||||||
|
opacity={0.58}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet
|
||||||
|
index={0}
|
||||||
|
enableDynamicSizing
|
||||||
|
enablePanDownToClose
|
||||||
|
onClose={onClose}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
backgroundStyle={styles.sheetBg}
|
||||||
|
handleIndicatorStyle={styles.handle}
|
||||||
|
>
|
||||||
|
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
|
||||||
|
{children}
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section label inside a sheet. */
|
||||||
|
export function EqSheetSection({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Text variant="caption" style={styles.section}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.itemRow}>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
|
||||||
|
) : null}
|
||||||
|
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
{trailing}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 (
|
||||||
|
<EqSheet onClose={onClose}>
|
||||||
|
<Text variant="heading" style={styles.title}>
|
||||||
|
Presets
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<EqSheetSection label="BUILT-IN" />
|
||||||
|
{builtIn.map((p) => (
|
||||||
|
<EqSheetItem
|
||||||
|
key={p.id}
|
||||||
|
label={p.name}
|
||||||
|
selected={p.id === activePresetId}
|
||||||
|
onPress={() => {
|
||||||
|
onApply(p.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<EqSheetSection label="CUSTOM" />
|
||||||
|
{custom.length === 0 ? (
|
||||||
|
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
|
||||||
|
No saved presets yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
custom.map((p) => (
|
||||||
|
<EqSheetItem
|
||||||
|
key={p.id}
|
||||||
|
label={p.name}
|
||||||
|
selected={p.id === activePresetId}
|
||||||
|
onPress={() => {
|
||||||
|
onApply(p.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
trailing={
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
onPress={() => onDelete(p.id)}
|
||||||
|
style={styles.delete}
|
||||||
|
accessibilityLabel={`Delete preset ${p.name}`}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EqSheetItem
|
||||||
|
label="Save current as preset…"
|
||||||
|
icon="bookmark-outline"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onSaveNew();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EqSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PresetSheet;
|
||||||
@@ -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 (
|
||||||
|
<EqSheet onClose={onClose}>
|
||||||
|
<Text variant="heading" style={styles.title}>
|
||||||
|
Save preset
|
||||||
|
</Text>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Preset name"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
maxLength={40}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (trimmed) {
|
||||||
|
onSave(trimmed);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||||
|
<Text variant="label" color={colors.textSecondary}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
|
||||||
|
disabled={!trimmed}
|
||||||
|
onPress={() => {
|
||||||
|
onSave(trimmed);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
Save
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</EqSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -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<EQBandType, string> = {
|
||||||
|
lowshelf: 'Low Shelf',
|
||||||
|
peaking: 'Peaking',
|
||||||
|
highshelf: 'High Shelf',
|
||||||
|
highpass: 'High Pass',
|
||||||
|
lowpass: 'Low Pass',
|
||||||
|
};
|
||||||
@@ -11,14 +11,16 @@ export function ScanProgress() {
|
|||||||
if (!isScanning) return null;
|
if (!isScanning) return null;
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
progress.phase === 'extracting'
|
progress.phase === 'analyzing'
|
||||||
? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}`
|
? `Analyzing audio… ${progress.processed}/${progress.total}`
|
||||||
: progress.total > 0
|
: progress.phase === 'extracting'
|
||||||
? `Found ${progress.total} files in ${progress.folderName ?? ''}…`
|
? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}`
|
||||||
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`;
|
: progress.total > 0
|
||||||
|
? `Found ${progress.total} files in ${progress.folderName ?? ''}…`
|
||||||
|
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`;
|
||||||
|
|
||||||
const fraction =
|
const fraction =
|
||||||
progress.phase === 'extracting' && progress.total > 0
|
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
|
||||||
? progress.processed / progress.total
|
? progress.processed / progress.total
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,74 @@ export async function getTrackCount(db: LibraryDatabase): Promise<number> {
|
|||||||
return row?.count ?? 0;
|
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<TrackLoudness | null> {
|
||||||
|
return (
|
||||||
|
(await db.get<TrackLoudness>(
|
||||||
|
`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<void> {
|
||||||
|
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<void> {
|
||||||
|
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) ----------------------------------------
|
// --- Settings (key-value preferences) ----------------------------------------
|
||||||
|
|
||||||
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {
|
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {
|
||||||
|
|||||||
+28
-2
@@ -4,11 +4,16 @@
|
|||||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
|
// 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);
|
// 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
|
// 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';
|
import type { LibraryDatabase } from './database';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 6;
|
export const SCHEMA_VERSION = 9;
|
||||||
|
|
||||||
// One statement per entry — op-sqlite executes single statements.
|
// One statement per entry — op-sqlite executes single statements.
|
||||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||||
@@ -115,6 +120,27 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
|||||||
)`,
|
)`,
|
||||||
`DROP TABLE IF EXISTS waveform_cache`,
|
`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<void> {
|
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { metadataToUpsertRow } from './trackAdapter';
|
|||||||
const EXTRACT_BATCH_SIZE = 24;
|
const EXTRACT_BATCH_SIZE = 24;
|
||||||
|
|
||||||
export interface ScanProgress {
|
export interface ScanProgress {
|
||||||
phase: 'discovering' | 'extracting';
|
phase: 'discovering' | 'extracting' | 'analyzing';
|
||||||
processed: number;
|
processed: number;
|
||||||
total: number;
|
total: number;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
@@ -157,6 +157,10 @@ export async function scanFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await markFolderScanned(db, folder.id);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { DEFAULT_OSC_GAIN } from './oscilloscopeGain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
||||||
@@ -8,11 +9,20 @@ import { create } from 'zustand';
|
|||||||
interface ScopeStore {
|
interface ScopeStore {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
setActive: (active: boolean) => void;
|
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<ScopeStore>((set) => ({
|
export const useScopeStore = create<ScopeStore>((set) => ({
|
||||||
active: false,
|
active: false,
|
||||||
setActive: (active) => set({ active }),
|
setActive: (active) => set({ active }),
|
||||||
|
oscGain: DEFAULT_OSC_GAIN,
|
||||||
|
setOscGain: (oscGain) => set({ oscGain }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
setNormalizationEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setNormalizationTargetLufs: (lufs: number) => Promise<void>;
|
||||||
|
setReplayGainEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setReplayGainMode: (mode: ReplayGainMode) => Promise<void>;
|
||||||
|
/** Current settings as the plain shape the gain resolver consumes. */
|
||||||
|
asNormalizationSettings: () => NormalizationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAudioSettingsStore = create<AudioSettingsStore>((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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
+264
-28
@@ -1,44 +1,280 @@
|
|||||||
import { create } from 'zustand';
|
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
|
* Parametric EQ state — SQLite (settings table) is the source of truth, mirrored
|
||||||
* at M4; for now this just holds the band model (ported `EQBand`) so the EQ
|
* in memory (mirrors the settingsStore pattern; no zustand-persist). Every band/
|
||||||
* screen and later DSP wiring share one shape.
|
* 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[] {
|
const PERSIST_DEBOUNCE_MS = 250;
|
||||||
return DEFAULT_FREQUENCIES.map((frequency) => ({
|
|
||||||
id: `band-${frequency}`,
|
function parseBands(json: string | null): EQBand[] | null {
|
||||||
type: 'peaking',
|
if (!json) return null;
|
||||||
frequency,
|
try {
|
||||||
gain: 0,
|
const arr = JSON.parse(json);
|
||||||
Q: 1.0,
|
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 {
|
interface EQStore {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
preamp: number; // dB
|
preamp: number; // dB
|
||||||
bands: EQBand[];
|
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<void>;
|
||||||
setEnabled: (enabled: boolean) => void;
|
setEnabled: (enabled: boolean) => void;
|
||||||
setPreamp: (preamp: number) => void;
|
toggleEnabled: () => void;
|
||||||
setBandGain: (id: string, gain: number) => void;
|
setPreamp: (db: number) => void;
|
||||||
reset: () => void;
|
addBand: (band?: Partial<EQBand>) => void;
|
||||||
|
removeBand: (id: string) => void;
|
||||||
|
updateBand: (id: string, updates: Partial<EQBand>) => 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<EQStore>((set) => ({
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
enabled: false,
|
|
||||||
preamp: 0,
|
|
||||||
bands: makeDefaultBands(),
|
|
||||||
|
|
||||||
setEnabled: (enabled) => set({ enabled }),
|
export const useEQStore = create<EQStore>((set, get) => {
|
||||||
setPreamp: (preamp) => set({ preamp }),
|
function syncToNative(): void {
|
||||||
setBandGain: (id, gain) =>
|
const { enabled, preamp, bands } = get();
|
||||||
set((state) => ({
|
setEqEnabledNative(enabled);
|
||||||
bands: state.bands.map((b) => (b.id === id ? { ...b, gain } : b)),
|
setEqPreampNative(enabled ? dbToLinear(preamp) : 1);
|
||||||
})),
|
setEqBandsNative(flattenBandsForNative(bands));
|
||||||
reset: () => set({ enabled: false, preamp: 0, bands: makeDefaultBands() }),
|
}
|
||||||
}));
|
|
||||||
|
function schedulePersist(): void {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer);
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
persistTimer = null;
|
||||||
|
void persistNow();
|
||||||
|
}, PERSIST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistNow(): Promise<void> {
|
||||||
|
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<EQStore>): 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
|
|||||||
export type FolderWithCount = LibraryFolder & { track_count: number };
|
export type FolderWithCount = LibraryFolder & { track_count: number };
|
||||||
|
|
||||||
interface ScanProgressState {
|
interface ScanProgressState {
|
||||||
phase: 'idle' | 'discovering' | 'extracting';
|
phase: 'idle' | 'discovering' | 'extracting' | 'analyzing';
|
||||||
processed: number;
|
processed: number;
|
||||||
total: number;
|
total: number;
|
||||||
folderName?: string;
|
folderName?: string;
|
||||||
|
|||||||
+6
-1
@@ -52,12 +52,17 @@ export interface PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EQ Band
|
// EQ Band
|
||||||
|
export type EQBandType = 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass';
|
||||||
|
|
||||||
export interface EQBand {
|
export interface EQBand {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass';
|
type: EQBandType;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
gain: number;
|
gain: number;
|
||||||
Q: 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
|
// EQ Preset
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface DbTrack {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
added_at: number;
|
added_at: number;
|
||||||
modified_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 {
|
export interface LibraryFolder {
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
#Wed Jun 17 14:38:54 EDT 2026
|
#Fri Jun 19 12:51:01 EDT 2026
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user