Skip to content

Commit 0eafb3c

Browse files
committed
Fix linux bluetooth bug
1 parent 5f31147 commit 0eafb3c

7 files changed

Lines changed: 66 additions & 4 deletions

File tree

packages/hifi/src/Sound.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const Sound: React.FC<SoundProps> = ({
2626
status,
2727
seek,
2828
volume,
29+
sampleRate,
2930
preload = 'auto',
3031
crossOrigin = '',
3132
onTimeUpdate,
@@ -36,7 +37,7 @@ export const Sound: React.FC<SoundProps> = ({
3637
children,
3738
}) => {
3839
const audioRef = useRef<HTMLAudioElement | null>(null);
39-
const context = useAudioContext();
40+
const context = useAudioContext(sampleRate);
4041
const { source } = useAudioElementSource(audioRef, context);
4142
const isReady = !!source;
4243
const [audioNodes, setAudioNodes] = useState<AudioNode[]>([]);
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { useEffect, useState } from 'react';
22

3-
export const useAudioContext = () => {
3+
export const useAudioContext = (sampleRate?: number) => {
44
const [context, setContext] = useState<AudioContext | null>(null);
55

66
useEffect(() => {
7-
const ctx = new AudioContext({ latencyHint: 'playback' });
7+
const ctx = new AudioContext({ latencyHint: 'playback', sampleRate });
88
setContext(ctx);
99

1010
return () => {
1111
ctx.close();
1212
};
13-
}, []);
13+
}, [sampleRate]);
1414

1515
return context;
1616
};

packages/hifi/src/test/Sound.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,31 @@ describe('Sound', () => {
136136
expect(playMock).toHaveBeenCalled();
137137
restore();
138138
});
139+
140+
it('passes the sampleRate prop through to the AudioContext constructor', () => {
141+
const { restore } = setupAudioContextMock();
142+
143+
render(<Sound src={httpSource} status="paused" sampleRate={48000} />);
144+
145+
expect(window.AudioContext).toHaveBeenCalledWith(
146+
expect.objectContaining({ sampleRate: 48000, latencyHint: 'playback' }),
147+
);
148+
149+
restore();
150+
});
151+
152+
it('constructs the AudioContext without a sampleRate when none is given', () => {
153+
const { restore } = setupAudioContextMock();
154+
155+
render(<Sound src={httpSource} status="paused" />);
156+
157+
expect(window.AudioContext).toHaveBeenCalledWith(
158+
expect.objectContaining({
159+
sampleRate: undefined,
160+
latencyHint: 'playback',
161+
}),
162+
);
163+
164+
restore();
165+
});
139166
});

packages/hifi/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type SoundProps = {
1313
status: SoundStatus;
1414
seek?: number;
1515
volume?: number;
16+
sampleRate?: number;
1617
preload?: HTMLAudioElement['preload'];
1718
crossOrigin?: ScriptHTMLAttributes<HTMLAudioElement>['crossOrigin'];
1819
onTimeUpdate?: (args: { position: number; duration: number }) => void;

packages/player/changelog.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
[
2+
{
3+
"date": "2026-07-01T00:00",
4+
"description": "Fix audio going silent after about a second over Bluetooth on Linux",
5+
"type": "fix",
6+
"contributors": ["nukeop"],
7+
"tags": [
8+
{
9+
"label": "Playback",
10+
"color": "green"
11+
}
12+
]
13+
},
214
{
315
"date": "2026-07-01T00:00",
416
"description": "Fix yt-dlp playback stalling at segment boundaries (e.g. 0:09, 0:29) when buffered audio drifted past the next segment's start time",

packages/player/src-tauri/src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,16 @@ fn apply_linux_workarounds() {
5858
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
5959
}
6060
}
61+
62+
// WebKitGTK's Web Audio GStreamer pipeline stalls on Bluetooth A2DP when
63+
// GStreamer autoselects pipewiresink: the pipeline is hardcoded to 44100 Hz
64+
// while A2DP sinks on PipeWire expect 48000 Hz, and audio goes silent after
65+
// about a second. Demoting pipewiresink's rank makes autoaudiosink fall back
66+
// to pulsesink (via pipewire-pulse), which resamples correctly.
67+
// Reported here: https://github.com/nukeop/nuclear/discussions/1980
68+
if std::env::var("GST_PLUGIN_FEATURE_RANK").is_err() {
69+
unsafe {
70+
std::env::set_var("GST_PLUGIN_FEATURE_RANK", "pipewiresink:NONE");
71+
}
72+
}
6173
}

packages/player/src/components/SoundProvider.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FC, PropsWithChildren } from 'react';
22
import { useCallback, useEffect } from 'react';
33

44
import { Sound, Volume } from '@nuclearplayer/hifi';
5+
import { usePlatform } from '@nuclearplayer/ui';
56

67
import { useCoreSetting } from '../hooks/useCoreSetting';
78
import { eventBus } from '../services/eventBus';
@@ -10,6 +11,11 @@ import { useQueueStore } from '../stores/queueStore';
1011
import { useSoundStore } from '../stores/soundStore';
1112
import { resolveErrorMessage } from '../utils/logging';
1213

14+
// WebKitGTK's Web Audio GStreamer pipeline is hardcoded to 44100 Hz, which
15+
// causes silent audio over Bluetooth A2DP (PipeWire sinks expect 48000 Hz).
16+
// Forcing the AudioContext to 48000 Hz on Linux avoids the mismatch.
17+
const LINUX_SAMPLE_RATE_HZ = 48000;
18+
1319
export const SoundProvider: FC<PropsWithChildren> = ({ children }) => {
1420
const { src, status, seek } = useSoundStore();
1521
const [crossfadeMs] = useCoreSetting<number>('playback.crossfadeMs');
@@ -18,6 +24,8 @@ export const SoundProvider: FC<PropsWithChildren> = ({ children }) => {
1824
const [volume01] = useCoreSetting<number>('playback.volume');
1925
const [muted] = useCoreSetting<boolean>('playback.muted');
2026
const volumePercent = muted ? 0 : Math.round((volume01 ?? 1) * 100);
27+
const platform = usePlatform();
28+
const sampleRate = platform === 'linux' ? LINUX_SAMPLE_RATE_HZ : undefined;
2129

2230
useEffect(() => {
2331
if (crossfadeMs !== undefined) {
@@ -71,6 +79,7 @@ export const SoundProvider: FC<PropsWithChildren> = ({ children }) => {
7179
status={status}
7280
seek={seek}
7381
volume={volumePercent}
82+
sampleRate={sampleRate}
7483
preload={preload}
7584
crossOrigin={crossOrigin}
7685
onTimeUpdate={handleTimeUpdate}

0 commit comments

Comments
 (0)