23 Commits
Author SHA1 Message Date
austinried 8bcca2767d settings: clamp the preamp and make the dialog test assert
Review follow-ups.

- The snap clamped nothing, so an out-of-range stored value would show a
  decibel label the slider cannot reach: clamps now live in the snap
  rather than duplicated in the step.
- The screenshot test only rendered the dialog without asserting it
  opened, so deleting the row's click would have left it passing while
  the capture showed the plain list. It now requires the dialog's Reset.
- "Adjust level on top of the ReplayGain" was not grammatical, and the
  mode prompt claimed the server normalises when it only supplies the
  tags.
- Drop a test assertion that restated the production formula.
2026-10-16 13:07:12 +00:00
austinried 5b6c0ef45c settings: name the toolkit in the slider comments
The in-repo comment rule allows a comment only when it names an external
system, version or issue, so both of these now name Compose Material 3,
which is what they are actually about.
2026-10-16 13:07:12 +00:00
austinried d245da20a4 settings: stop the preamp slider drawing a tick per step
Fifty-nine steps across a phone-width track is a tick every few dps,
which reads as a smudge on the bar. The steps are what make the slider
snap in 0.5 dB increments, so they stay; only the tick drawing is
suppressed, through a SliderDefaults.Track that skips drawTick.
2026-10-16 13:07:11 +00:00
austinried 730ea11235 settings: name the setting ReplayGain and rework the preamp dialog
- Call it ReplayGain, which is the name of the standard, in the row, the
  prompt and the preamp prompt.
- Give the slider room under the prompt instead of sitting against it.
- Put Reset in the button row, where it lines up with Cancel and Done,
  rather than above them, and centre the decibel value that is left in
  the content.
- Add a screenshot test that scrolls to the section and opens the dialog,
  which also composes the new rows; the existing screenshot only captures
  the top of the list, so it never rendered them.
2026-10-16 13:07:11 +00:00
austinried 31859b66b6 settings: make the preamp easier to set exactly
The slider alone is awkward for the two things people actually do with a
preamp: zero it, and nudge it by a known amount.

- Minus and plus buttons flank the slider, stepping one grid notch (0.5
  dB) and disabling at the ends.
- A Reset button sits under it, beside the current value, disabled when
  the value is already zero.
- Both go through the same snapping the slider does, so a button cannot
  land off the grid.
2026-10-16 13:07:11 +00:00
austinried 0b4e1d18bc settings: snap the preamp to its grid and drop a duplicate header
Review follow-ups.

- Remove the second "Playback" section header the rows were added
  under; the section already had one, so the screen rendered it twice.
- Snap the preamp to the 0.5 dB grid in the dialog. Material's slider
  snaps in pixel space and converts back, so the value it reports is near
  the configured grid but not on it: -1.0 dB arrives as -0.99999905, was
  displayed verbatim and was stored that way until the next drag. The
  step count is now derived from the range rather than hardcoded.
- Format the decibel value with the resource locale so translated
  layouts get their own decimal separator.
- The controls move to `ReplayGainControls.kt`, since the file now holds
  the grid as well as the labels, and the snap is covered by a test that
  reproduces the off-grid value without it.
2026-10-16 13:07:11 +00:00
austinried 229d6de4c6 settings: add the replay gain controls
Final slice: the screen that drives the mode and preamp preferences the
playback side already reads. Until now `setReplayGainMode` and
`setReplayGainPreampDb` had no production caller, so Off and the preamp
were unreachable.

- `SettingsViewModel` exposes both preferences and writes them back.
- The Playback section gains a "Replay gain" row opening an Off / Track /
  Album choice, and a "Preamp" row opening a slider across the +-15 dB
  range in 0.5 dB steps.
- `ReplayGainMode` is ordered Off, Track, Album so the choice list reads
  that way; the stored value is the name, so the order is not persisted
  and the default is still Track.
- The labels live in their own file so the signed decibel formatting can
  be tested, as `formatTrackTime` does.
2026-10-16 13:07:11 +00:00
austinried 72784ee914 playback: warn once when float output would skip the processor
Float output is the one way the audio processor chain is skipped on this
build, and it is a flag the renderers factory is handed. Nothing sets it,
so nothing happens today; if it is ever turned on, this says so once at
sink construction rather than leaving replay gain silently inapplicable
to high-resolution tracks.

Offload has no equivalent check: it is not a flag this code sets, and
AudioSink.setOffloadMode is only reachable by building the audio renderer
by hand. The factory comment names it instead.
2026-10-16 13:07:08 +00:00
austinried 8f34c958fb playback: reject non-finite gains before the pow, and filter peaks first
Review follow-ups for the apply slice.

- A negative-infinite gain produced 0 dB, not unity: 10^(inf/20) is 0.0,
  which is finite, so the result guard never fired and the track played
  silent — the opposite of what the guard is for. A large-enough finite
  negative gain underflows the same way, and an infinite gain paired with
  a peak survived as the cap. The gain is now rejected before the pow and
  an underflowed result falls back to unity.
- A peak carrying no data shadowed a usable peak in the other slot, so
  trackPeak=0 with albumPeak=0.5 skipped the cap entirely — the gonic
  shape on the default mode. Both peaks are filtered before one is chosen.
- Drop the claim that a period-less flush comes from a sink reset; it does
  not, and the guard stands on its own.
- Trim two comments back to naming the Media3 types.
- Revert the observeStar rename, which was for a path that no longer
  applies the gain.
2026-10-16 13:07:08 +00:00
austinried aee631c08d playback: drop comments that explain rather than name
The in-repo comment rule now allows a comment only when it names an
external system, version or issue, so the ones carrying rationale move
to this message.

- why the gain rides on the media item: PlaybackService can then apply it
  the moment the item becomes current instead of after a library round
  trip
- that ReplayGainController is written from the playback thread by
  onFlush and from the app thread by the preference collector
- that an empty passthrough input is the pipeline's own shared buffer,
  which cannot be put into itself
2026-10-16 13:07:08 +00:00
austinried 62409898c1 docs: record how replay gain reaches the audio sink
The Playback section already documents the star button's dependence on
unstable Media3 APIs and what to re-check when bumping the library. The
gain path needs the same treatment: it relies on flush carrying the
timeline and period, on the sink configuring per media item, and on the
processor chain being reached at all.
2026-10-16 13:07:07 +00:00
austinried cf272c4edc playback: apply the gain only from the item flush
Review follow-ups for the apply slice.

- Drop the media item transition application. It never supplied a gain
  the flush had not already supplied, and as a second writer it raced the
  playback thread: it applied the incoming item's gain while the outgoing
  item's buffers were still queued, scaling the previous track's tail by
  the next track's gain (reproduced at +12 dB). The flush is the only
  writer now.

  This reverses the earlier decision to keep it as a fallback. That was
  made on the premise that it was insurance against a flush that could
  not be resolved. The flush covers every transition, and a period-less
  flush only happens while the sink is being reset, so the premise does
  not hold.

- Ignore a flush that carries no period rather than reading it as "this
  item has no gain". Resetting to unity there would have been wrong if a
  bare flush ever arrived mid-playback; the test that pinned that
  behaviour now pins the opposite.

- Guard the pass-through branch against an empty input, which is the
  pipeline's own shared buffer and cannot be put into itself.

- The controller is written from the playback thread now, so its state
  updates and the recompute that follows them are synchronized rather
  than two independent volatile writes.

- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
  asserts it hands the processor the item being configured. That is the
  media3 behaviour this whole feature rests on, and it was previously
  asserted only by comments.
2026-10-16 13:07:07 +00:00
austinried c92d28c9dd playback: apply the gain at the media item flush
The app-thread transition callback was only ever a stopgap: media3 posts
it after the playback thread has already rendered, so the first buffers
of a new item could be scaled by the previous item's gain.

media3 forwards the playback timeline and the current period to
AudioProcessor.flush, and DefaultAudioSink flushes the processors on
every sink configuration, which happens at each media item transition
even when the format is unchanged. That is early enough to set the gain
before any of the new item's buffers are processed, on the playback
thread. See androidx/media#418.

The processor resolves the period back to its MediaItem and reports it,
so the gain maths and the preferences stay in ReplayGainController. That
controller is now written from two threads, so its state is volatile.

The transition callback stays as the fallback for a flush that carries no
period: without it a stale gain would persist for a whole track rather
than for a few milliseconds.
2026-10-16 13:07:07 +00:00
austinried 7ce754f95a playback: apply the item's gain in one tested step
Review follow-ups.

- `ReplayGainController.applyItem` owns reading the gain off a
  `MediaItem`, so the transition, the controller and the extras read are
  exercised together rather than only in isolation.
- drop the leftover `setTrack(null)` on a missing active source: now that
  the transition sets the gain from the item, that line only raced it and
  could reset a playing item to unity.
- tests for a zero gain surviving the extras round trip (zero is a real
  gain, not an absent one), for the latest item's gain being the one left
  applied, and for a missing item applying unity.
2026-10-16 13:07:07 +00:00
austinried 5f5ad85dff playback: apply replay gain from the media item
The gain was resolved by querying the library from the media item
transition callback, so the buffers the player had already begun
queueing for the new track could be scaled by the previous track's gain.
A quiet track followed by a hot one could clip at the boundary for as
long as that query took.

Carry the gain on `QueueItem` so `queueMediaItem` puts it on the
`MediaItem` extras, and apply it from the item that just became current,
with no library round trip.

That narrows the window rather than closing it: media3 delivers
`onMediaItemTransition` on the app thread after the playback thread has
already rendered for that iteration, so the very first buffers after a
transition can still carry the previous item's gain. The gap is now one
handler hop instead of a database query.

The trade-off is staleness: if a sync changes a track's gain while that
track is queued, the item keeps the old value until it is rebuilt.
Applying a new gain mid-track is of marginal value anyway.
2026-10-16 13:07:07 +00:00
austinried 3c0372cceb playback: reject non-finite replay gain
Review follow-ups for the apply slice.

The parse guard in the base branch keeps non-finite gains and peaks out
of the library, but nothing here should depend on that: a value already
stored, or written by a future caller, must not be able to silence a
track or blast it at full scale.

- the gain maths ignores a non-finite peak and falls back to unity when
  the result is not finite.
- the processor falls back to unity for a non-finite gain, so
  `roundToInt` can never be handed a NaN.
- the preamp is sanitised on both read and write, since `coerceIn` passes
  NaN straight through.
- the renderers factory is pulled out of `PlaybackService` into
  `ReplayGainRenderersFactory` so a test can assert the sink really is
  built with the processor in its chain; the wiring was otherwise
  untested.
- a null active source clears the gain rather than leaving the previous
  track's applied.
2026-10-16 13:07:07 +00:00
austinried e33784ddd3 feat: apply replay gain during playback
Second slice: read the gain synced in the first one and scale the decoded
PCM by it.

- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
  float samples, saturating on overflow rather than wrapping. It is
  installed through `DefaultAudioSink`'s audio processor chain, so it runs
  ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
  mode, falls back to the other gain when one is missing, adds the preamp,
  and caps the result at the peak's headroom so a boost cannot clip. A
  non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
  track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
  state and applies it on every media item transition.

The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
2026-10-16 13:07:07 +00:00
austinried 81e6189ebe sync: keep every replayGain element's fields
A second <replayGain> element for the same song cleared whatever the
first one set, because each field was assigned unconditionally and an
absent attribute parses to null. No server is known to split the object
across elements, but the merge costs nothing and the alternative loses
fields silently.

Also covers peaks arriving without gains, which the existing cases did
not.
2026-10-16 13:06:20 +00:00
austinried 069a0f7f19 sync: drop non-finite replay gain values at the parse boundary
`toFloatOrNull` accepts `"Infinity"`, `"NaN"` and overflowing literals
like `"1e40"`, so a server (or a corrupt tag it read) could put a
non-finite gain or peak into the library. An infinite peak later caps the
applied gain at negative infinity, i.e. silence; an infinite gain clips
every sample to full scale.

Nothing consumes these columns yet, so this is only about not storing
values that cannot mean anything.
2026-10-16 13:06:20 +00:00
austinried 86d0d06e76 sync: fail loudly on upsert column/value drift
Review follow-ups for the replay gain slice.

`upsertChanged` never checked that its hand-maintained column list and
the row values line up, and SQLite binds a missing trailing value as NULL
without error: adding a column to `songColumns` but not to `Song.values()`
would have written NULLs silently. It now asserts the sizes match before
preparing, so that mistake fails the sync instead.

The replay gain sync test also now re-syncs identical values and asserts
no rows are rewritten, pinning the REAL change-detection clause; an
update there rewrites the `song_search` FTS row for no reason.
2026-10-16 13:06:20 +00:00
austinried 526373c10a feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.

- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
  and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
  dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected

baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.

Nothing applies the values yet; this slice only mirrors them.
2026-10-16 13:06:20 +00:00
austinried 7b17e33545 sync: batch the fallback getAlbum song fetches
The `search3`-less fallback emitted one batch per album, and
`SyncService.writeSongs` opens a transaction per batch, so syncing a large
library that way ran one transaction per album — thousands of fsyncs, and an
invalidation round for every list observing `songs` with each.

Accumulate what each `getAlbum` returns and flush at `PAGE_SIZE`, so the
fallback writes the same ~500-row batches every other fetch already does.
Albums are fetched concurrently and complete out of order, so the accumulator
sits behind a mutex and the send happens outside it.

Batches are in-flight memory, so the flush is per rows reached rather than
per album page. Measured per `Song` (strings, boxed longs and genre list
included): ~0.7 KiB, so a 500-row batch is ~334 KiB plus ~51 KiB of the
`names`/`genres` structures `writeSongs` derives, where a whole album page
(~5,500 rows) would be ~3.8 MiB. `albumSongs()` is also a bare `channelFlow`,
whose channel holds 64 elements whatever their size, so 500-row batches would
have made its worst case ~21 MiB; it now carries the same `.buffer(1)` that
`entityBatches()` already uses on itself. The rest of the in-flight rows
scale with `syncConcurrency`, so that leaves the default's batch in hand and
a few MiB at the highest setting.

`SubsonicSourceTest.albumSongsAreEmittedInBoundedBatches` asserts the emitted
sizes, [500, 100] for 600 songs spread over 200 three-song albums, which pins
the carried remainder as well as the flush: verified it fails on the
pre-change code (200 batches of 3) and against a mutant that drops the
remainder ([500, 99]). `docs/architecture.md`'s page cap was 20x out of date,
so it is corrected alongside. `ktlintCheck`, `testDebugUnitTest` and
`lintDebug` pass.
2026-10-16 12:39:42 +00:00
austinried 6cfc1d29f1 ci: fit the whole Nix store in the GitHub cache
The GitHub job's store is ~7.3 GiB but gc-max-store-size-linux is 6G, and
nothing roots the devshells, so cache-nix-action's pre-save `nix store gc`
deletes whichever paths cross the cap before every save. The snapshots land
missing the JDK and the Android SDK components, so both `nix develop` steps
re-download or rebuild them on every run.

Raise the cap to 10G, which clears the store, and bump the key to nix-v3-. The
first run after that downloads the full closure once and saves a complete
snapshot; the gutted nix-v2- snapshots are not restored, so there is one cold
run per ref and v3 is warm from then on.

Compressed at ~0.47x its store the snapshot is ~4.7 GiB at worst, inside the
10 GB per-repo budget. What pushes the repo over that budget today is the four
stale gradle- (v1) entries, which no restore-key can reach and which age out by
LRU.
2026-10-16 09:02:57 +00:00
35 changed files with 3332 additions and 50 deletions
+3 -3
View File
@@ -9,6 +9,6 @@ runs:
extra_nix_config: experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v2-
gc-max-store-size-linux: 6G
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v3-
gc-max-store-size-linux: 10G
File diff suppressed because it is too large Load Diff
@@ -384,6 +384,16 @@ val MIGRATION_26_27 =
}
}
val MIGRATION_27_28 =
object : Migration(27, 28) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -444,4 +454,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_24_25,
MIGRATION_25_26,
MIGRATION_26_27,
MIGRATION_27_28,
)
@@ -49,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 27,
version = 28,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -264,6 +264,10 @@ data class Song(
@ColumnInfo(defaultValue = "0") val created: Long = 0,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
@Ignore val genres: List<String> = emptyList(),
)
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -51,8 +52,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album }
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
@@ -146,6 +152,28 @@ class UserPreferences(
private val scrobblingKey = booleanPreferencesKey("scrobbling")
fun replayGainMode(): Flow<ReplayGainMode> =
store.data.map { prefs ->
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
}
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
suspend fun setReplayGainPreampDb(value: Float) {
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
}
private fun Float?.sanitizedPreampDb(): Float =
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) {
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.xml.sax.Attributes
@@ -205,7 +207,7 @@ class SubsonicSource(
),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
raw += batch.size
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
@@ -224,6 +226,23 @@ class SubsonicSource(
private fun albumSongs(): Flow<List<Song>> =
channelFlow {
val permits = Semaphore(maxConcurrentFetches)
val lock = Mutex()
val pending = ArrayList<Song>(PAGE_SIZE)
suspend fun accept(songs: List<Song>) {
lock
.withLock {
pending.addAll(songs)
if (pending.size < PAGE_SIZE) {
null
} else {
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
pending.subList(0, PAGE_SIZE).clear()
batch
}
}?.let { send(it) }
}
var offset = 0
var pages = 0
while (true) {
@@ -248,10 +267,10 @@ class SubsonicSource(
params = mapOf("id" to albumId),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
if (accepted.isNotEmpty()) send(accepted)
accept(accepted)
}
}
}
@@ -265,7 +284,14 @@ class SubsonicSource(
if (pages > maxPages) throw pageCapExceeded()
offset += PAGE_SIZE
}
}
val tail =
lock.withLock {
val held = pending.toList()
pending.clear()
held
}
if (tail.isNotEmpty()) send(tail)
}.buffer(1)
private fun supportsEmptyQuerySearch(): Boolean {
emptyQuerySearchSupported?.let { return it }
@@ -78,6 +78,17 @@ object SubsonicXml {
played = attrs.dateAttr("played"),
)
internal fun songChild(
entity: SongDraft,
name: String,
attrs: Attributes,
) {
when (name) {
"genres" -> entity.addGenre(attrs)
"replayGain" -> entity.addReplayGain(attrs)
}
}
internal fun playlist(
sourceId: Long,
attrs: Attributes,
@@ -184,6 +195,10 @@ internal class SongDraft(
private val created: Long = 0L,
private val playCount: Long = 0L,
private val played: Long? = null,
private var trackGain: Float? = null,
private var albumGain: Float? = null,
private var trackPeak: Float? = null,
private var albumPeak: Float? = null,
private val genres: MutableList<String> = mutableListOf(),
) {
fun addGenre(attrs: Attributes) {
@@ -194,6 +209,13 @@ internal class SongDraft(
?.let { genres += it }
}
fun addReplayGain(attrs: Attributes) {
attrs.floatAttr("trackGain")?.let { trackGain = it }
attrs.floatAttr("albumGain")?.let { albumGain = it }
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
}
fun toSong(): Song =
Song(
sourceId = sourceId,
@@ -211,6 +233,10 @@ internal class SongDraft(
created = created,
playCount = playCount,
played = played,
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(),
)
}
@@ -219,6 +245,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
private const val ROOT_TAG = "subsonic-response"
@@ -314,6 +314,10 @@ private val songColumns =
"created",
"playCount",
"played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -358,6 +362,10 @@ private fun Song.values() =
created,
playCount,
played,
trackGain,
albumGain,
trackPeak,
albumPeak,
)
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
@@ -374,6 +382,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
values: (T) -> List<Any?>,
) {
if (rows.isEmpty()) return
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
@@ -401,6 +410,7 @@ private fun SQLiteStatement.bind(
is Int -> bindLong(index, value.toLong())
is Boolean -> bindLong(index, if (value) 1L else 0L)
is Double -> bindDouble(index, value)
is Float -> bindDouble(index, value.toDouble())
is String -> bindText(index, value)
else -> error("Cannot bind ${value::class}")
}
@@ -234,11 +234,35 @@ class Media3PlayerHandle(
mediaItem.mediaMetadata.extras
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(),
)
}
private const val EXTRA_COVER_ART_ID = "coverArtId"
private const val EXTRA_DURATION_MS = "durationMs"
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
internal fun MediaItem?.replayGain(): ReplayGain {
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
return ReplayGain(
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
)
}
private fun Bundle.putGain(
key: String,
value: Float?,
) {
if (value != null) putFloat(key, value)
}
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
internal fun queueMediaItem(
item: QueueItem,
@@ -260,6 +284,10 @@ internal fun queueMediaItem(
Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId)
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
},
).build()
val builder =
@@ -34,6 +34,7 @@ data class QueueItem(
val album: String?,
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
)
data class QueueContext(
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity
import com.subtracks.R
import com.subtracks.UserAgent
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
private var starred = false
private var starJob: Job? = null
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
private val starCallback =
object : MediaSession.Callback {
override fun onConnect(
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val preferences = GlobalContext.get().get<UserPreferences>()
val cache = StreamCache.open(this)
scope.launch {
preferences
.replayGainMode()
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
.collect(replayGain::setSettings)
}
val exoPlayer =
ExoPlayer
.Builder(this)
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
.setAudioAttributes(
AudioAttributes
.Builder()
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
album = song.album,
coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 },
replayGain = song.replayGain(),
)
@@ -0,0 +1,49 @@
package com.subtracks.playback
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
data class ReplayGain(
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
)
internal data class ReplayGainSettings(
val mode: ReplayGainMode,
val preampDb: Float,
)
internal fun Song.replayGain(): ReplayGain =
ReplayGain(
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
)
internal fun replayGainLinearGain(
gain: ReplayGain,
settings: ReplayGainSettings,
): Float {
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
// "no peak data" and is ignored rather than treated as silence.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val (requestedDb, peak) =
when (settings.mode) {
ReplayGainMode.Off -> return 1f
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
}
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
@@ -0,0 +1,27 @@
package com.subtracks.playback
import androidx.media3.common.MediaItem
import com.subtracks.data.prefs.ReplayGainMode
internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
applyGain()
}
@Synchronized
fun setSettings(value: ReplayGainSettings) {
settings = value
applyGain()
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings))
}
}
@@ -0,0 +1,71 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.common.util.UnstableApi
import java.nio.ByteBuffer
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal class ReplayGainProcessor(
private val onItem: (MediaItem) -> Unit = {},
) : BaseAudioProcessor() {
@Volatile
private var linearGain = 1f
fun setLinearGain(value: Float) {
linearGain = value
}
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
// item transition even when the format is unchanged, and hands over the timeline and the period
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
// thread receives afterwards.
override fun onFlush(streamMetadata: StreamMetadata) {
streamMetadata.currentItem()?.let(onItem)
}
override fun queueInput(inputBuffer: ByteBuffer) {
val output = replaceOutputBuffer(inputBuffer.remaining())
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT -> {
while (inputBuffer.remaining() >= 2) {
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
}
}
C.ENCODING_PCM_FLOAT -> {
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
}
else -> {
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
}
}
inputBuffer.position(inputBuffer.limit())
output.flip()
}
private companion object {
const val MIN_SAMPLE = -32768
const val MAX_SAMPLE = 32767
}
}
@OptIn(UnstableApi::class)
internal fun StreamMetadata.currentItem(): MediaItem? {
val uid = periodUid ?: return null
val index = timeline.getIndexOfPeriod(uid)
if (index == C.INDEX_UNSET) return null
val period = timeline.getPeriod(index, Timeline.Period())
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
}
@@ -0,0 +1,38 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import com.subtracks.log.Log
@OptIn(UnstableApi::class)
internal class ReplayGainRenderersFactory(
context: Context,
private val processor: ReplayGainProcessor,
) : DefaultRenderersFactory(context) {
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
// and AudioSink.setOffloadMode bypasses it entirely.
public override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParameters: Boolean,
): AudioSink {
if (enableFloatOutput) {
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
}
return DefaultAudioSink
.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
.setAudioProcessors(arrayOf(processor))
.build()
}
private companion object {
const val FLOAT_OUTPUT_TAG = "replaygain"
}
}
@@ -0,0 +1,44 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import com.subtracks.R
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.roundToInt
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
internal val REPLAY_GAIN_PREAMP_STEPS =
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
// showing or storing it.
internal fun snapReplayGainPreampDb(value: Float): Float =
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
internal fun stepReplayGainPreampDb(
value: Float,
steps: Int,
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
internal fun replayGainModeLabel(
resources: Resources,
mode: ReplayGainMode,
): String =
when (mode) {
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
}
internal fun replayGainPreampLabel(
resources: Resources,
decibels: Float,
): String {
val sign = if (decibels > 0f) "+" else ""
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
}
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,6 +71,9 @@ import com.subtracks.BuildConfig
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log
@@ -84,7 +92,16 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
private enum class SettingsDialog {
WifiQuality,
MobileQuality,
DownloadQuality,
SyncConcurrency,
SyncInterval,
SyncMode,
ReplayGain,
ReplayGainPreamp,
}
@Composable
fun SettingsRoute(
@@ -108,6 +125,8 @@ fun SettingsRoute(
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
SettingsScreen(
@@ -121,6 +140,8 @@ fun SettingsRoute(
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
@@ -133,6 +154,8 @@ fun SettingsRoute(
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
@@ -176,6 +199,8 @@ fun SettingsScreen(
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
replayGainPreamp: Float = 0f,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
@@ -186,6 +211,8 @@ fun SettingsScreen(
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
@@ -337,6 +364,22 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
@@ -519,6 +562,22 @@ fun SettingsScreen(
)
}
SettingsDialog.ReplayGain -> {
ReplayGainModeDialog(
selected = replayGainMode,
onSelect = onReplayGainModeChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.ReplayGainPreamp -> {
ReplayGainPreampDialog(
selected = replayGainPreamp,
onSelect = onReplayGainPreampChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
@@ -672,6 +731,118 @@ private fun SyncModeDialog(
)
}
@Composable
private fun ReplayGainModeDialog(
selected: ReplayGainMode,
onSelect: (ReplayGainMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.settings_replay_gain_prompt),
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplayGainPreampDialog(
selected: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
text = {
Column {
Text(
stringResource(R.string.settings_replay_gain_preamp_prompt),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Remove,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
)
}
Slider(
value = draft,
onValueChange = { draft = snapReplayGainPreampDb(it) },
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
steps = REPLAY_GAIN_PREAMP_STEPS,
// Compose Material 3 draws one track tick per step, and at 59 steps they
// smear together, so the ticks are suppressed and the snapping kept.
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Add,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
)
}
}
Text(
replayGainPreampLabel(resources, draft),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = { draft = 0f },
enabled = draft != 0f,
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
}
},
)
}
@Composable
private fun QualityDialog(
title: String,
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
@@ -65,6 +66,16 @@ class SettingsViewModel(
.scrobbling()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
val replayGainMode: StateFlow<ReplayGainMode> =
userPreferences
.replayGainMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
val replayGainPreamp: StateFlow<Float> =
userPreferences
.replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
@@ -115,4 +126,12 @@ class SettingsViewModel(
fun setScrobbling(enabled: Boolean) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
}
fun setReplayGainMode(mode: ReplayGainMode) {
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
}
fun setReplayGainPreamp(value: Float) {
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
}
}
+11
View File
@@ -74,6 +74,17 @@
<string name="settings_music_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_replay_gain_mode_album">Album gain</string>
<string name="settings_replay_gain_mode_off">Off</string>
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain</string>
<string name="settings_replay_gain_preamp_reset">Reset</string>
<string name="settings_replay_gain_preamp_title">Preamp</string>
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
<string name="settings_replay_gain_title">ReplayGain</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -188,6 +188,34 @@ class UserPreferencesTest {
prefs.setVerboseLogging(true)
assertEquals(true, prefs.verboseLogging().first())
file.delete()
}
@Test
fun replayGainDefaultsToTrackModeAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Album)
prefs.setReplayGainPreampDb(3.5f)
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(99f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(-99f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(Float.NaN)
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
file.delete()
}
}
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun songsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><searchResult3>" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></searchResult3></subsonic-response>",
)
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun albumSongsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><albumList2>" +
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
"</albumList2></subsonic-response>",
)
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></album></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun entitiesWithEmptyIdsAreDropped() =
runBlocking {
@@ -608,6 +677,40 @@ class SubsonicSourceTest {
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
}
@Test
fun albumSongsAreEmittedInBoundedBatches() =
runBlocking {
val albumCount = 200
val songsPerAlbum = 3
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(albumListOf(albumCount))
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(albumWithSongs(url.queryParameter("id")!!, songsPerAlbum))
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val batches = SubsonicSource(1, client()).songs().toList().map { it.size }
assertEquals(listOf(500, 100), batches)
}
@Test
fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking {
@@ -705,6 +808,13 @@ class SubsonicSourceTest {
"<song id=\"$albumId-s1\" title=\"One\"/>" +
"</album></subsonic-response>"
private fun albumWithSongs(
albumId: String,
count: Int,
) = "<subsonic-response status=\"ok\"><album id=\"$albumId\">" +
(1..count).joinToString("") { "<song id=\"$albumId-s$it\" title=\"Title $it\"/>" } +
"</album></subsonic-response>"
private fun failed(
code: Int,
message: String,
@@ -33,6 +33,14 @@ class SubsonicXmlTest {
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
).map { it.toAlbum() }
private fun songs(xml: String) =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = SubsonicXml::songChild,
).map { it.toSong() }
@Test
fun mapsArtists() {
val artists =
@@ -156,51 +164,155 @@ class SubsonicXmlTest {
@Test
fun mapsSongGenresAndIgnoresEmptyOnes() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
fun mapsSongReplayGain() {
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Gained">
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
val song = songs.single()
assertEquals(-3.2f, song.trackGain)
assertEquals(-2.1f, song.albumGain)
assertEquals(0.98f, song.trackPeak)
assertEquals(0.99f, song.albumPeak)
}
@Test
fun leavesMissingReplayGainFieldsUnset() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Track gain only">
<replayGain trackGain="-3.2"/>
</song>
<song id="s2" title="No gain"/>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(null, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(null, songs[1].trackPeak)
assertEquals(null, songs[1].albumPeak)
}
@Test
fun mapsReplayGainPeaksWithoutGains() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Peaks only">
<replayGain trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs.single().trackGain)
assertEquals(null, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun keepsTheFieldsOfEveryReplayGainElement() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Split">
<replayGain trackGain="-3.2"/>
<replayGain trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(0.98f, songs.single().trackPeak)
}
@Test
fun ignoresNonFiniteReplayGainValues() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Junk">
<replayGain trackGain="Infinity" albumGain="NaN" trackPeak="1e40" albumPeak="-Infinity"/>
</song>
<song id="s2" title="Mostly fine">
<replayGain trackGain="-3.2" albumGain="NaN" trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(-3.2f, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(0.98f, songs[1].trackPeak)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(emptyList<String>(), songs.single().genres)
}
@@ -887,6 +887,47 @@ class SyncServiceTest {
assertEquals(before, totalChanges())
}
@Test
fun storesReplayGainAndClearsItWhenTheServerStopsSendingIt() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(
trackGain = -3.2f,
albumGain = -2.1f,
trackPeak = 0.98f,
albumPeak = 0.99f,
),
),
)
SyncService(db, source).sync()
val stored = db.libraryDao().song(1, "s1").first()!!
assertEquals(-3.2f, stored.trackGain)
assertEquals(-2.1f, stored.albumGain)
assertEquals(0.98f, stored.trackPeak)
assertEquals(0.99f, stored.albumPeak)
val before = totalChanges()
SyncService(db, source).sync()
assertEquals(before, totalChanges())
source.songs = listOf(song("s1"))
SyncService(db, source).sync()
val cleared = db.libraryDao().song(1, "s1").first()!!
assertEquals(null, cleared.trackGain)
assertEquals(null, cleared.albumGain)
assertEquals(null, cleared.trackPeak)
assertEquals(null, cleared.albumPeak)
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->
@@ -54,4 +54,39 @@ class MediaItemTest {
assertEquals("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
}
@Test
fun theItemCarriesTheReplayGain() {
val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f)
val item =
queueMediaItem(
song.copy(replayGain = gain),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(gain, item.replayGain())
}
@Test
fun anItemWithoutGainReadsBackAsNoGain() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals(ReplayGain(), item.replayGain())
}
@Test
fun aZeroGainIsNotMistakenForNoGain() {
val item =
queueMediaItem(
song.copy(replayGain = ReplayGain(trackGain = 0f, trackPeak = 0.5f)),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(ReplayGain(trackGain = 0f, trackPeak = 0.5f), item.replayGain())
}
}
@@ -0,0 +1,60 @@
package com.subtracks.playback
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Song
import org.junit.Assert.assertEquals
import org.junit.Test
class QueueItemTest {
@Test
fun carriesTheSongsReplayGain() {
val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
trackGain = -3.2f,
albumGain = -2.1f,
trackPeak = 0.98f,
albumPeak = 0.99f,
)
val item = AlbumSongItem(song, coverArt = "al1").toQueueItem()
assertEquals(
ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f),
item.replayGain,
)
}
@Test
fun aSongWithoutGainCarriesNoGain() {
val item =
AlbumSongItem(
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
),
coverArt = null,
).toQueueItem()
assertEquals(ReplayGain(), item.replayGain)
}
}
@@ -0,0 +1,82 @@
package com.subtracks.playback
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReplayGainControllerTest {
private val applied = mutableListOf<Float>()
private val controller = ReplayGainController { applied += it }
@Test
fun appliesTheItemsGain() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun recomputesWhenTheSettingsChange() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun switchingOffRestoresUnityGain() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun anItemWithoutGainPlaysAtUnity() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = null))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun theLatestItemLeavesItsGainApplied() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = 6.0206f))
controller.applyItem(item("b", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
private fun item(
id: String,
trackGain: Float?,
) = queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -0,0 +1,205 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.ByteBuffer
import java.nio.ByteOrder
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainProcessorTest {
@Test
fun scalesSixteenBitSamples() {
val processor = processor(gain = 0.5f)
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughAtUnityGain() {
val processor = processor(gain = 1f)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
val processor = processor(gain = 4f)
processor.queueInput(sixteenBit(20000, -20000))
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
}
@Test
fun scalesFloatSamples() {
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
processor.queueInput(floatBuffer(1f, -0.5f))
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
}
@Test
fun passesAnUnsupportedEncodingThroughUnchanged() {
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsNaN() {
val processor = processor(gain = Float.NaN)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsInfinite() {
val processor = processor(gain = Float.POSITIVE_INFINITY)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
val processor = ReplayGainProcessor()
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
assertEquals(true, processor.isActive())
}
@Test
fun aFlushReportsTheItemBeingFlushed() {
val reported = mutableListOf<MediaItem>()
val item = item("a", trackGain = -6.0206f)
ReplayGainProcessor { reported += it }.flush(streamMetadata(item))
assertEquals(listOf(item), reported)
}
@Test
fun aFlushWithoutAPeriodLeavesTheGainAlone() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.flush(StreamMetadata.DEFAULT)
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
}
@Test
fun aFlushMovesTheGainToTheNextItem() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(2000), readShorts(processor.getOutput()))
}
private fun processor(
gain: Float,
encoding: Int = C.ENCODING_PCM_16BIT,
): ReplayGainProcessor =
ReplayGainProcessor().apply {
setLinearGain(gain)
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
flush(StreamMetadata.DEFAULT)
}
private fun item(
id: String,
trackGain: Float,
): MediaItem =
queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private fun streamMetadata(item: MediaItem): StreamMetadata {
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
return StreamMetadata
.Builder()
.setTimeline(timeline)
.setPeriodUid(timeline.getUidOfPeriod(0))
.build()
}
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private fun floatBuffer(vararg samples: Float): ByteBuffer =
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putFloat(it) }
flip()
}
private fun readShorts(buffer: ByteBuffer): List<Int> =
buildList {
while (buffer.remaining() >= 2) add(buffer.short.toInt())
}
private fun readFloats(buffer: ByteBuffer): List<Float> =
buildList {
while (buffer.remaining() >= 4) add(buffer.float)
}
private fun readBytes(buffer: ByteBuffer): List<Byte> =
buildList {
while (buffer.hasRemaining()) add(buffer.get())
}
private companion object {
const val SAMPLE_RATE = 44_100
const val CHANNELS = 2
const val DURATION_US = 1_000_000L
}
}
@@ -0,0 +1,33 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.audio.AudioProcessorChain
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainRenderersFactoryTest {
@Test
fun installsTheGainProcessorInTheAudioSink() {
val context = ApplicationProvider.getApplicationContext<Context>()
val processor = ReplayGainProcessor()
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
// DefaultAudioSink exposes no accessor for the chain it was built with.
val chain =
DefaultAudioSink::class.java
.getDeclaredField("audioProcessorChain")
.apply { isAccessible = true }
.get(sink) as AudioProcessorChain
assertEquals(true, chain.audioProcessors.any { it === processor })
}
}
@@ -0,0 +1,66 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.ByteBuffer
import java.nio.ByteOrder
// Pins the media3 behaviour the whole feature rests on: DefaultAudioSink hands the processor the
// timeline and the period being configured, on every configuration, which is what lets the gain be
// set before a new item's buffers. See androidx/media#418.
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainSinkTest {
@Test
fun theSinkHandsTheItemBeingConfiguredToTheProcessor() {
val context = ApplicationProvider.getApplicationContext<Context>()
val reported = mutableListOf<MediaItem>()
val processor = ReplayGainProcessor { reported += it }
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
val item = MediaItem.Builder().setMediaId("b").build()
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
sink.configure(
AudioSink.AudioSinkConfig
.Builder(pcmFormat())
.setTimeline(timeline)
.setMediaPeriodId(MediaPeriodId(timeline.getUidOfPeriod(0)))
.build(),
)
sink.handleBuffer(sixteenBit(1000), 0, 1)
assertEquals(listOf(item), reported)
}
private fun pcmFormat() =
Format
.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setPcmEncoding(C.ENCODING_PCM_16BIT)
.setChannelCount(2)
.setSampleRate(44_100)
.build()
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private companion object {
const val DURATION_US = 1_000_000L
}
}
@@ -0,0 +1,195 @@
package com.subtracks.playback
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplayGainTest {
@Test
fun trackModeAppliesTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun trackModeFallsBackToTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeAppliesTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeFallsBackToTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun offAppliesUnityGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Off, 0f),
),
TOLERANCE,
)
}
@Test
fun aSongWithoutGainPlaysAtUnity() {
assertEquals(
1f,
replayGainLinearGain(ReplayGain(), ReplayGainSettings(ReplayGainMode.Track, 3f)),
TOLERANCE,
)
}
@Test
fun thePreampShiftsTheAppliedGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsAPositiveGain() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsUsingTheAlbumPeakWhenTheTrackPeakIsMissing() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, albumPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun aMissingOrZeroPeakDoesNotCap() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
val expected = 3.9810717f
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
}
@Test
fun aTruePeakAboveFullScaleCapsBelowUnity() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = 6f, trackPeak = 2f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakNeverLowersAnAttenuation() {
assertEquals(
0.31622776f,
replayGainLinearGain(
ReplayGain(trackGain = -10f, trackPeak = 0.9f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun aNonFinitePeakDoesNotCap() {
assertEquals(
3.9810717f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun playsAtUnityWhenTheGainIsNotFinite() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.POSITIVE_INFINITY), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NEGATIVE_INFINITY), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NaN), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = -Float.MAX_VALUE), settings), TOLERANCE)
}
@Test
fun playsAtUnityWhenANonFiniteGainWouldOtherwiseBeCapped() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = Float.POSITIVE_INFINITY, trackPeak = 0.001f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakFallsBackWhenThePreferredOneCarriesNoData() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = 0f, albumPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData
@@ -340,6 +344,22 @@ class AppScreenshotTest {
@Test
fun settings() {
setSettingsContent()
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
}
@Test
fun replayGainPreampDialog() {
setSettingsContent()
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Preamp"))
composeRule.onAllNodesWithText("Preamp")[0].performClick()
composeRule.waitForIdle()
composeRule.onAllNodesWithText("Reset").assertCountEquals(1)
composeRule.onRoot().captureRoboImage("src/test/screenshots/replay_gain_preamp.png")
}
private fun setSettingsContent() {
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
@@ -367,8 +387,6 @@ class AppScreenshotTest {
)
}
}
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
}
@Test
@@ -0,0 +1,59 @@
package com.subtracks.ui.settings
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReplayGainControlsTest {
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test
fun modeLabelsNameTheGainEachOneUses() {
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
}
@Test
fun preampLabelsCarryTheSignAndTheUnit() {
assertEquals("+3.0 dB", replayGainPreampLabel(resources, 3f))
assertEquals("-2.5 dB", replayGainPreampLabel(resources, -2.5f))
assertEquals("0.0 dB", replayGainPreampLabel(resources, 0f))
}
@Test
fun theSliderHasOneStepPerHalfDecibel() {
assertEquals(59, REPLAY_GAIN_PREAMP_STEPS)
}
@Test
fun thePreampStepsAlongTheGridAndStopsAtTheEnds() {
assertEquals(-1.5f, stepReplayGainPreampDb(-1f, -1), 0f)
assertEquals(-0.5f, stepReplayGainPreampDb(-1f, 1), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MIN_REPLAY_GAIN_PREAMP_DB, -1), 0f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MAX_REPLAY_GAIN_PREAMP_DB, 1), 0f)
}
@Test
fun thePreampSnapsBackOntoTheHalfDecibelGrid() {
assertEquals(0f, snapReplayGainPreampDb(-0f), 0f)
assertEquals(-1f, snapReplayGainPreampDb(-0.99999905f), 0f)
assertEquals(-3f, snapReplayGainPreampDb(-3.000001f), 0f)
assertEquals(14.5f, snapReplayGainPreampDb(14.6f), 0f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(14.9f), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-14.9f), 0f)
}
@Test
fun thePreampIsClampedToItsRange() {
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(20f), 0f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-20f), 0f)
}
}
+3 -1
View File
@@ -98,6 +98,8 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one.
## Data model
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
@@ -120,7 +122,7 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs, though it still coalesces the songs into 500-row batches before writing them. Every paged loop stops on a short page and is capped at 20,000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.