diff --git a/.gitignore b/.gitignore
index 9e70f57ac5..af96cafb86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,6 @@ a.out
bin/
!openpilot/starpilot/galaxy/bin/
!openpilot/starpilot/soloist/pulseaudio/bin/
-!openpilot/starpilot/soloist/bluealsa/bin/
# created at launch for comma hardware PYTHONPATH (PC uses editable installs via pyproject.toml)
/msgq
@@ -48,7 +47,6 @@ bin/
!openpilot/tools/replay/libmedia_mux.so
!openpilot/starpilot/soloist/pulseaudio/lib/*.so
!openpilot/starpilot/soloist/pulseaudio/modules/*.so
-!openpilot/starpilot/soloist/bluealsa/lib/*.so
*.a
st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z]
*.unchunked
diff --git a/.trail/learnings/2026-10-22-rebasing-stardust-onto-the-existing-pr-danny.md b/.trail/learnings/2026-10-22-rebasing-stardust-onto-the-existing-pr-danny.md
new file mode 100644
index 0000000000..485eb3ef35
--- /dev/null
+++ b/.trail/learnings/2026-10-22-rebasing-stardust-onto-the-existing-pr-danny.md
@@ -0,0 +1,23 @@
+---
+title: Rebasing Stardust onto the existing PR-Smaller-4 split
+author: Danny
+date: 2026-10-22
+branch: integration/stardust-smaller4-20261022
+touches:
+ - openpilot/starpilot/galaxy/spotify_setup.py
+ - openpilot/starpilot/galaxy/tests/test_spotify.mjs
+ - openpilot/starpilot/galaxy/tests/test_spotify_setup.py
+ - openpilot/starpilot/galaxy/web/css/galaxy.css
+ - openpilot/starpilot/galaxy/web/js/spotify.js
+ - openpilot/starpilot/soloist/backend.py
+ - openpilot/starpilot/soloist/bluealsa/README.md
+ - openpilot/starpilot/soloist/preferences.py
+ - openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py
+---
+
+## Decided
+Use backup/Stardust-Spotify-Companion-pre-split-20261019 as the replay boundary when rebasing the October 22 Stardust tip onto PR-Smaller-4. The split already contains the older features, and PR-Smaller-5 is an ancestor of PR-Smaller-4. Replay the 13 later commits, preserving upstream UI and troubleshooting links.
+## Tried and ruled out
+A plain rebase tries 121 old commits, causing duplicate feature conflicts and temporarily reintroducing logging removed later. The partial clone lacks object 4569f738be4f7dab405c63f8db8f23872c1be190 for the intermediate volume commit; the net diff from ddc9244f0b to the saved Stardust tip is available and preserves the final volume behavior.
+## Watch out
+Do not overwrite active PR-Smaller-4 worktree edits. Keep backup branches and a binary patch. Refresh the combined prebuilt inventory receipt. Soloist tests need local socket permission; Galaxy server tests additionally require the native Params library. No device build or deployment was performed.
diff --git a/openpilot/starpilot/galaxy/spotify_setup.py b/openpilot/starpilot/galaxy/spotify_setup.py
index 4e6455cc63..aee132a8cd 100644
--- a/openpilot/starpilot/galaxy/spotify_setup.py
+++ b/openpilot/starpilot/galaxy/spotify_setup.py
@@ -166,8 +166,10 @@ class SpotifySetup:
raise SetupRejected('Invalid Spotify settings')
values = {**current.__dict__, **{fields[key]: value for key, value in payload.items()}}
changes_output = values['output'] != current.output or values['output_address'] != current.output_address
+ # Flipping between Spotify and the already chosen phone is a playback control; picking a different phone is setup.
+ if values['phone_address'] != current.phone_address:
+ self._require_parked('change the phone')
if values['source'] != current.source or values['phone_address'] != current.phone_address:
- self._require_parked('change the music source')
if values['phone_address']:
match = next((d for d in self.status(session)['devices'] if d['address'] == str(values['phone_address']).upper()), None)
if match is None:
diff --git a/openpilot/starpilot/galaxy/tests/test_spotify.mjs b/openpilot/starpilot/galaxy/tests/test_spotify.mjs
index dd57127b33..50e7f8bbbd 100644
--- a/openpilot/starpilot/galaxy/tests/test_spotify.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_spotify.mjs
@@ -5,7 +5,7 @@ import { SpotifyFeed, SpotifyPage, archivePreflight, importProblem, installCheck
const setup = {
enabled: true, parked: true, keyPresent: true, audioReady: true,
release: { installed: true, version: "1.3.9.32", expires: "2027-01-14T00:00:00+00:00", daysLeft: 80, level: "ok", expired: false },
- preferences: { deviceName: "StarPilot", cacheMb: 256, initialVolume: 60, output: "bluetooth", outputAddress: "AA:BB:CC:DD:EE:FF", outputName: "Car" },
+ preferences: { deviceName: "StarPilot", cacheMb: 256, initialVolume: 100, output: "bluetooth", outputAddress: "AA:BB:CC:DD:EE:FF", outputName: "Car" },
devices: [{ address: "AA:BB:CC:DD:EE:FF", name: "Car", connected: true }], service: null,
import: { state: "idle" }, maxUploadBytes: 64 * 1024 * 1024, archiveName: "soloist_release_arm64.tar.gz", cacheChoices: [100, 256],
links: { download: DOWNLOAD_PAGE, key: "https://developer.spotify.com/dashboard/soloist", help: "https://developer.spotify.com/" },
diff --git a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py
index 48ab354ea8..50ce0cb3e9 100644
--- a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py
+++ b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py
@@ -181,7 +181,9 @@ def test_phone_source_requires_park_and_a_paired_device_without_spotify(owner):
setup, state, layout, _ = owner
state['parked'] = False
with pytest.raises(SetupRejected, match='Park'):
- setup.save_preferences(SESSION, {'source': 'phone'})
+ setup.save_preferences(SESSION, {'source': 'phone', 'phoneAddress': 'AA:BB:CC:DD:EE:FF'})
+ assert setup.save_preferences(SESSION, {'source': 'phone'})['preferences']['source'] == 'phone' # flipping source works while driving
+ assert setup.save_preferences(SESSION, {'source': 'spotify'})['preferences']['source'] == 'spotify'
state['parked'] = True
with pytest.raises(SetupRejected, match='Pair your phone'):
setup.save_preferences(SESSION, {'source': 'phone', 'phoneAddress': '11:22:33:44:55:66'})
diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css
index 7e41e826e7..308fa63157 100644
--- a/openpilot/starpilot/galaxy/web/css/galaxy.css
+++ b/openpilot/starpilot/galaxy/web/css/galaxy.css
@@ -1757,13 +1757,12 @@ progress { max-width: 100%; accent-color: var(--primary); }
input[type="range"].gx-slider.gx-slider--galaxy,
[data-theme="light"] input[type="range"].gx-slider.gx-slider--galaxy { height: 10px; border: none;
background: linear-gradient(90deg, var(--secondary), var(--primary)) 0 0 / var(--fill, 0%) 100% no-repeat, var(--surface-container-high); }
-.gx-spotify-now__main { display: flex; gap: var(--sp-3); align-items: center; min-width: 0; }
+.gx-spotify-now__main { display: flex; gap: var(--sp-3); align-items: center; justify-content: center; min-width: 0; }
.gx-spotify-now__text { min-width: 0; }
.gx-spotify-now__text h3, .gx-spotify-now__text p { margin: 0; }
.gx-spotify-now__progress { display: grid; gap: 2px; }
-.gx-spotify-now__controls { align-items: center; flex-wrap: wrap; }
+.gx-spotify-now__controls { align-items: center; justify-content: center; flex-wrap: wrap; }
.gx-spotify-now__play { min-width: 64px; font-size: 1.4rem; }
-.gx-spotify-now__volume { display: flex; flex: 1 1 160px; align-items: center; gap: var(--sp-2); min-width: 0; }
/* Auto Learn results use existing cards and plot colors. */
.gx-flm__facts { display: flex; flex-wrap: wrap; gap: 1rem 2rem; }
.gx-flm__facts dd { margin: .3rem 0 0; }
diff --git a/openpilot/starpilot/galaxy/web/js/spotify.js b/openpilot/starpilot/galaxy/web/js/spotify.js
index cce6dddfe0..785a00c968 100644
--- a/openpilot/starpilot/galaxy/web/js/spotify.js
+++ b/openpilot/starpilot/galaxy/web/js/spotify.js
@@ -258,7 +258,7 @@ export const SpotifyPage = {
setup: null, busy: false, error: "", uploadProgress: null, now: Date.now(),
installOpen: false, removeOpen: false, resetOpen: false, showSetup: false, downloadStarted: false,
archiveFile: null, archiveMagicError: "", archiveChecking: false, archiveSelection: 0, installAttempted: false, installBaseline: null,
- keyValue: "", keyEditing: false, nameValue: "", volumeDraft: null, pauseDraft: null, skipDraft: null,
+ keyValue: "", keyEditing: false, nameValue: "", pauseDraft: null, skipDraft: null,
}),
mounted() {
this.feed = new SpotifyFeed({ publish: (value) => Object.assign(this, value, { now: Date.now() }), unauthorized: this.unauthorized })
@@ -388,11 +388,6 @@ export const SpotifyPage = {
this.skipDraft = null
if (Number.isFinite(value)) return this.feed.post("./api/spotify/preferences", { skipMute: value })
},
- commitVolume(event) {
- const volume = Math.round(Number(event.target.value))
- this.volumeDraft = null
- if (Number.isInteger(volume) && volume >= 0 && volume <= 100) return this.control("volume", { volume })
- },
openInstall() { this.chooseAgain(); Object.assign(this, { installOpen: true, downloadStarted: false }) },
closeInstall() { this.installOpen = false },
markDownloadStarted() {
@@ -444,7 +439,7 @@ export const SpotifyPage = {
Music Source
- Put the car in Park or use offroad mode to change the source.
+ Put the car in Park or use offroad mode to change the phone.
@@ -501,10 +496,8 @@ export const SpotifyPage = {
-
+ Volume {{ player.volume }}% - change in Spotify app if needed, otherwise use car volume
diff --git a/openpilot/starpilot/soloist/backend.py b/openpilot/starpilot/soloist/backend.py
index 0cf6009a86..e7de95f23e 100644
--- a/openpilot/starpilot/soloist/backend.py
+++ b/openpilot/starpilot/soloist/backend.py
@@ -34,6 +34,7 @@ SYSTEM_ALSA_CONFIG = Path('/usr/share/alsa/alsa.conf')
# The Android Auto media channel format every head unit must offer; the pipe runs on the system clock,
# so it produces exactly real-time audio (silence when paused) whether or not anyone reads it.
PIPE_RATE, PIPE_CHANNELS, PIPE_BYTES_PER_FRAME = 48000, 2, 4
+PLAYER_VOLUME = 100 # level is the car's/speaker's job; the saved initial_volume preference is ignored
class BackendUnavailable(RuntimeError):
@@ -55,7 +56,7 @@ def alsa_config(address: str, base: Path = SYSTEM_ALSA_CONFIG) -> str:
if any(character in str(base) for character in '\n\r<>"'):
raise BackendUnavailable('Invalid system ALSA configuration path')
return (f'<{base}>\n' + bluealsa_runtime.alsa_plugins() +
- f'\npcm.{PCM_NAME} {{\n type bluealsa\n device "{address}"\n profile "a2dp"\n softvol "true"\n' +
+ f'\npcm.{PCM_NAME} {{\n type bluealsa\n device "{address}"\n profile "a2dp"\n volume "100+"\n softvol "true"\n' +
' hint { show on description "StarPilot Spotify output" }\n}\n')
@@ -117,7 +118,7 @@ def player_launch(layout: Layout, prefs: Preferences, api_key: str) -> Launch:
# The key must be an argument: Soloist documents no file, fd or environment alternative.
argv = [str(layout.current / 'soloist'), '--device-name', prefs.device_name, '--api-key', api_key,
'--data-dir', str(layout.state), '--cache-dir', str(layout.cache), '--cache-size', str(prefs.cache_mb),
- '--initial-volume', str(prefs.initial_volume), '--ws', '127.0.0.1:0']
+ '--initial-volume', str(PLAYER_VOLUME), '--ws', '127.0.0.1:0']
env = {'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'PATH': '/usr/bin:/bin',
'PULSE_SERVER': f'unix:{paths["socket"]}'}
return Launch(argv, env)
diff --git a/openpilot/starpilot/soloist/bluealsa/README.md b/openpilot/starpilot/soloist/bluealsa/README.md
index da355e9261..5be68d5cb1 100644
--- a/openpilot/starpilot/soloist/bluealsa/README.md
+++ b/openpilot/starpilot/soloist/bluealsa/README.md
@@ -24,7 +24,9 @@ inactivity and short input interruptions. It inserts silence; it does not
reconstruct missing music or eliminate interference. Playback leaves `hwcompat`
at its default so a disconnected output is not silently consumed. Both paths
explicitly set `softvol "true"` to preserve the previous software-volume mode
-instead of adopting 5.0's new native-volume default. Initial device volume state
+instead of adopting 5.0's new native-volume default. Playback also opens
+at `volume "100+"` (100%, unmuted) because 5.0 persists each device's last volume
+and applies it to samples in soft-volume mode. Phone capture is left unchanged. Initial device volume state
is separate from 4.1; the user's existing 4.1 state is never rewritten.
The build retains SBC reception, SBC/LDAC transmission, and oFono support. The
diff --git a/openpilot/starpilot/soloist/preferences.py b/openpilot/starpilot/soloist/preferences.py
index c2fb971290..e2e0c6f83b 100644
--- a/openpilot/starpilot/soloist/preferences.py
+++ b/openpilot/starpilot/soloist/preferences.py
@@ -19,7 +19,7 @@ MUTE_MAX = 1.5
class Preferences:
device_name: str = 'StarPilot'
cache_mb: int = 256
- initial_volume: int = 60
+ initial_volume: int = 100
output: str = 'bluetooth' # or 'android_auto' (the car's media channel), or 'test' (a silent sink)
output_address: str = ''
output_name: str = ''
diff --git a/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py b/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py
index 326eeee78b..36b9408ccc 100644
--- a/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py
+++ b/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py
@@ -19,6 +19,8 @@ def test_capture_and_playback_select_matching_private_plugins():
for config in (capture, playback):
assert runtime.alsa_plugins() in config
assert 'softvol "true"' in config
+ assert 'volume "100+"' in playback # saved state must never leave Spotify quiet or muted
+ assert 'volume' not in capture # phone audio level was already right; leave it to the phone
assert 'hwcompat "silence"' in capture
assert 'hwcompat' not in playback
diff --git a/prebuilt.json b/prebuilt.json
index 19b11e2da4..a51a5b8d6e 100644
--- a/prebuilt.json
+++ b/prebuilt.json
@@ -1 +1 @@
-{"protected_inventory_sha256": "d2437878b07e69107e1dbfd6ca4f498524a730e7346c1e8082468685453a1260", "schema_version": 1}
+{"protected_inventory_sha256": "db604052cc7583a2c2a82def4aa1cfa0c50149a69bf2f051ecceb9062e94e90d", "schema_version": 1}