loukai-app 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +4 -3
- package/scripts/ensure-electron.js +164 -0
- package/src/main/handlers/index.js +5 -0
- package/src/main/handlers/streamingHandlers.js +70 -0
- package/src/main/preload.js +31 -0
- package/src/main/webServer.js +77 -1
- package/src/renderer/components/AppRoot.jsx +4 -0
- package/src/renderer/dist/assets/{kaiPlayer-DSaY7TxC.js → kaiPlayer-BsM-WzYQ.js} +2 -2
- package/src/renderer/dist/assets/kaiPlayer-BsM-WzYQ.js.map +1 -0
- package/src/renderer/dist/assets/streamingSender-HwIev870.js +2 -0
- package/src/renderer/dist/assets/streamingSender-HwIev870.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js.map +1 -0
- package/src/renderer/dist/renderer.js +15 -15
- package/src/renderer/dist/renderer.js.map +1 -1
- package/src/renderer/js/kaiPlayer.js +15 -0
- package/src/renderer/js/streamingSender.js +292 -0
- package/src/renderer/js/webrtcManager.js +3 -0
- package/src/shared/components/PlayerControls.jsx +12 -0
- package/src/shared/hooks/useStreamingSender.js +30 -0
- package/src/web/App.jsx +1 -0
- package/src/web/dist/assets/index-DUPLO3h6.js +11 -0
- package/src/web/dist/assets/{index-CGbmW1VG.js.map → index-DUPLO3h6.js.map} +1 -1
- package/src/web/dist/index.html +1 -1
- package/static/viewer.html +421 -0
- package/src/renderer/dist/assets/kaiPlayer-DSaY7TxC.js.map +0 -1
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +0 -2
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +0 -1
- package/src/web/dist/assets/index-CGbmW1VG.js +0 -11
|
@@ -30,6 +30,7 @@ export class KAIPlayer extends PlayerInterface {
|
|
|
30
30
|
masterGain: null,
|
|
31
31
|
analyser: null, // For butterchurn visualization
|
|
32
32
|
vocalsPAGain: null, // For backup:PA feature - vocals to PA routing
|
|
33
|
+
streamDestination: null, // MediaStreamAudioDestinationNode for browser viewer streaming
|
|
33
34
|
},
|
|
34
35
|
IEM: {
|
|
35
36
|
sourceNodes: new Map(),
|
|
@@ -106,6 +107,12 @@ export class KAIPlayer extends PlayerInterface {
|
|
|
106
107
|
this.outputNodes.PA.vocalsPAGain.gain.value = 0; // Muted by default
|
|
107
108
|
this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain);
|
|
108
109
|
|
|
110
|
+
// Parallel MediaStream destination for browser viewer streaming.
|
|
111
|
+
// Connected to masterGain in addition to (not instead of) the audio device destination
|
|
112
|
+
// so PA continues playing locally while the viewer receives the same mix.
|
|
113
|
+
this.outputNodes.PA.streamDestination = this.audioContexts.PA.createMediaStreamDestination();
|
|
114
|
+
this.outputNodes.PA.masterGain.connect(this.outputNodes.PA.streamDestination);
|
|
115
|
+
|
|
109
116
|
// Initialize IEM audio context with validated device
|
|
110
117
|
const iemContextOptions = {};
|
|
111
118
|
if (this.outputDevices.IEM !== 'default' && 'sinkId' in AudioContext.prototype) {
|
|
@@ -950,6 +957,14 @@ export class KAIPlayer extends PlayerInterface {
|
|
|
950
957
|
return this.songData?.metadata?.duration || 0;
|
|
951
958
|
}
|
|
952
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Get the PA bus audio as a MediaStream, for piping into browser viewers via WebRTC.
|
|
962
|
+
* Returns null if audio hasn't initialized yet.
|
|
963
|
+
*/
|
|
964
|
+
getPAStream() {
|
|
965
|
+
return this.outputNodes.PA.streamDestination?.stream ?? null;
|
|
966
|
+
}
|
|
967
|
+
|
|
953
968
|
getMixerState() {
|
|
954
969
|
return {
|
|
955
970
|
PA: this.mixerState.PA,
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming Sender - Broadcasts the karaoke canvas + PA audio to browser viewers via WebRTC.
|
|
3
|
+
*
|
|
4
|
+
* One renderer-side instance holds a Map<viewerId, RTCPeerConnection>. Each browser tab
|
|
5
|
+
* that opens the /viewer page (after admin auth) becomes a viewer with its own peer
|
|
6
|
+
* connection. The web server brokers signaling between this sender and each viewer
|
|
7
|
+
* over Socket.IO.
|
|
8
|
+
*
|
|
9
|
+
* Separate from webrtcManager.js (which handles the single Electron canvas window).
|
|
10
|
+
* Both can run simultaneously.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ICE_SERVERS = []; // LAN-only by default; the web server is reachable so direct ICE works
|
|
14
|
+
|
|
15
|
+
export class StreamingSender {
|
|
16
|
+
constructor() {
|
|
17
|
+
/** @type {Map<string, RTCPeerConnection>} */
|
|
18
|
+
this.peers = new Map();
|
|
19
|
+
/** @type {Map<string, Array>} ICE candidates queued before remote description is set */
|
|
20
|
+
this.pendingICE = new Map();
|
|
21
|
+
|
|
22
|
+
this.canvasStream = null;
|
|
23
|
+
this.audioStream = null;
|
|
24
|
+
|
|
25
|
+
this._listenersBound = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lazily capture the karaoke canvas video + PA bus audio. */
|
|
29
|
+
ensureSourceStreams() {
|
|
30
|
+
if (!this.canvasStream) {
|
|
31
|
+
const canvas = document.getElementById('karaokeCanvas');
|
|
32
|
+
if (!canvas) throw new Error('karaokeCanvas not found');
|
|
33
|
+
this.canvasStream = canvas.captureStream(60);
|
|
34
|
+
console.log(
|
|
35
|
+
'[stream-sender] captured karaokeCanvas, video tracks:',
|
|
36
|
+
this.canvasStream.getVideoTracks().length
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!this.audioStream) {
|
|
41
|
+
// KAIPlayer is exposed via window.app.player.kaiPlayer (see ElectronBridge.js)
|
|
42
|
+
const paStream = window.app?.player?.kaiPlayer?.getPAStream?.();
|
|
43
|
+
if (paStream) {
|
|
44
|
+
this.audioStream = paStream;
|
|
45
|
+
console.log(
|
|
46
|
+
'[stream-sender] grabbed PA stream, audio tracks:',
|
|
47
|
+
paStream.getAudioTracks().length
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
console.warn('[stream-sender] 🔇 No PA stream; viewers will get video only');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wire up IPC listeners for signaling events from the web server. Idempotent. */
|
|
56
|
+
bindSignalingListeners() {
|
|
57
|
+
if (this._listenersBound) return;
|
|
58
|
+
if (!window.kaiAPI?.streaming) {
|
|
59
|
+
console.error('[stream-sender] streaming IPC bridge not exposed');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
window.kaiAPI.streaming.onViewerJoin(({ viewerId }) => {
|
|
64
|
+
console.log('[stream-sender] viewer joined:', viewerId);
|
|
65
|
+
this.handleViewerJoin(viewerId);
|
|
66
|
+
});
|
|
67
|
+
window.kaiAPI.streaming.onViewerAnswer(({ viewerId, answer }) => {
|
|
68
|
+
console.log('[stream-sender] received answer from', viewerId);
|
|
69
|
+
this.handleViewerAnswer(viewerId, answer);
|
|
70
|
+
});
|
|
71
|
+
window.kaiAPI.streaming.onViewerICE(({ viewerId, candidate }) => {
|
|
72
|
+
this.handleViewerICE(viewerId, candidate);
|
|
73
|
+
});
|
|
74
|
+
window.kaiAPI.streaming.onViewerLeave(({ viewerId }) => {
|
|
75
|
+
console.log('[stream-sender] viewer left:', viewerId);
|
|
76
|
+
this.cleanupViewer(viewerId);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this._listenersBound = true;
|
|
80
|
+
console.log('[stream-sender] signaling listeners bound');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A new viewer joined — build a peer connection, attach tracks, send an offer. */
|
|
84
|
+
async handleViewerJoin(viewerId) {
|
|
85
|
+
try {
|
|
86
|
+
this.ensureSourceStreams();
|
|
87
|
+
|
|
88
|
+
const pc = new RTCPeerConnection({
|
|
89
|
+
iceServers: ICE_SERVERS,
|
|
90
|
+
iceCandidatePoolSize: 4,
|
|
91
|
+
bundlePolicy: 'balanced',
|
|
92
|
+
rtcpMuxPolicy: 'require',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.peers.set(viewerId, pc);
|
|
96
|
+
this.pendingICE.set(viewerId, []);
|
|
97
|
+
|
|
98
|
+
// Video track from canvas
|
|
99
|
+
let videoSender = null;
|
|
100
|
+
let videoTransceiver = null;
|
|
101
|
+
this.canvasStream.getVideoTracks().forEach((track) => {
|
|
102
|
+
const tx = pc.addTransceiver(track, {
|
|
103
|
+
direction: 'sendonly',
|
|
104
|
+
streams: [this.canvasStream],
|
|
105
|
+
});
|
|
106
|
+
videoSender = tx.sender;
|
|
107
|
+
videoTransceiver = tx;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Prefer H.264 baseline (hardware decode on virtually every device —
|
|
111
|
+
// phone GPUs, smart-TV browsers, Steam Deck). Fall back to VP8.
|
|
112
|
+
if (videoTransceiver && typeof videoTransceiver.setCodecPreferences === 'function') {
|
|
113
|
+
try {
|
|
114
|
+
const caps = RTCRtpSender.getCapabilities('video');
|
|
115
|
+
const preferred = (caps?.codecs ?? [])
|
|
116
|
+
.filter((c) => {
|
|
117
|
+
if (
|
|
118
|
+
c.mimeType.includes('H264') &&
|
|
119
|
+
c.sdpFmtpLine?.includes('profile-level-id=42e01f')
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (c.mimeType.includes('VP8')) return true;
|
|
124
|
+
return false;
|
|
125
|
+
})
|
|
126
|
+
.sort((a, _b) => (a.mimeType.includes('H264') ? -1 : 1));
|
|
127
|
+
if (preferred.length > 0) {
|
|
128
|
+
videoTransceiver.setCodecPreferences(preferred);
|
|
129
|
+
console.log(
|
|
130
|
+
'[stream-sender] preferred codecs:',
|
|
131
|
+
preferred.map((c) => c.mimeType).join(', ')
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn('[stream-sender] codec preference failed:', err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Audio track from PA bus
|
|
140
|
+
if (this.audioStream) {
|
|
141
|
+
this.audioStream.getAudioTracks().forEach((track) => {
|
|
142
|
+
pc.addTransceiver(track, { direction: 'sendonly', streams: [this.audioStream] });
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Apply video encoding parameters. getParameters() before negotiation
|
|
147
|
+
// can return an empty `encodings` array — retry after negotiation
|
|
148
|
+
// settles. The peers-map guard prevents these timers from running
|
|
149
|
+
// after the viewer has disconnected.
|
|
150
|
+
const applyVideoEncoding = async () => {
|
|
151
|
+
if (!this.peers.has(viewerId)) return;
|
|
152
|
+
if (!videoSender) return;
|
|
153
|
+
try {
|
|
154
|
+
const params = videoSender.getParameters();
|
|
155
|
+
params.degradationPreference = 'maintain-resolution';
|
|
156
|
+
if (params.encodings?.length) {
|
|
157
|
+
// High ceiling for LAN viewers; WebRTC's bandwidth estimator
|
|
158
|
+
// will adapt downward automatically for any viewer on a worse
|
|
159
|
+
// path (cellular, congested WiFi, slow Ethernet, etc.).
|
|
160
|
+
// No min-bitrate floor — would force quality loss to fail
|
|
161
|
+
// instead of degrade on poor links.
|
|
162
|
+
params.encodings[0].maxBitrate = 25_000_000; // 25 Mbps
|
|
163
|
+
params.encodings[0].maxFramerate = 60;
|
|
164
|
+
params.encodings[0].scaleResolutionDownBy = 1.0;
|
|
165
|
+
}
|
|
166
|
+
await videoSender.setParameters(params);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.warn(`[stream-sender] setParameters for ${viewerId} failed:`, err.message);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
applyVideoEncoding();
|
|
172
|
+
setTimeout(applyVideoEncoding, 2000);
|
|
173
|
+
setTimeout(applyVideoEncoding, 5000);
|
|
174
|
+
|
|
175
|
+
pc.onicecandidate = (event) => {
|
|
176
|
+
if (event.candidate) {
|
|
177
|
+
window.kaiAPI.streaming.sendViewerICE({
|
|
178
|
+
viewerId,
|
|
179
|
+
candidate: {
|
|
180
|
+
candidate: event.candidate.candidate,
|
|
181
|
+
sdpMid: event.candidate.sdpMid,
|
|
182
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
pc.onconnectionstatechange = () => {
|
|
189
|
+
if (
|
|
190
|
+
pc.connectionState === 'failed' ||
|
|
191
|
+
pc.connectionState === 'closed' ||
|
|
192
|
+
pc.connectionState === 'disconnected'
|
|
193
|
+
) {
|
|
194
|
+
this.cleanupViewer(viewerId);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const offer = await pc.createOffer();
|
|
199
|
+
await pc.setLocalDescription(offer);
|
|
200
|
+
|
|
201
|
+
window.kaiAPI.streaming.sendViewerOffer({
|
|
202
|
+
viewerId,
|
|
203
|
+
offer: { type: offer.type, sdp: offer.sdp },
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error(`Failed to handle viewer join (${viewerId}):`, err);
|
|
207
|
+
this.cleanupViewer(viewerId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async handleViewerAnswer(viewerId, answer) {
|
|
212
|
+
const pc = this.peers.get(viewerId);
|
|
213
|
+
if (!pc) {
|
|
214
|
+
console.warn(`Answer for unknown viewer ${viewerId}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
219
|
+
|
|
220
|
+
// Drain any ICE candidates that arrived before the remote description was set
|
|
221
|
+
const queue = this.pendingICE.get(viewerId) ?? [];
|
|
222
|
+
for (const candidate of queue) {
|
|
223
|
+
try {
|
|
224
|
+
// eslint-disable-next-line no-await-in-loop
|
|
225
|
+
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.warn('Queued ICE add failed:', err);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.pendingICE.set(viewerId, []);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`Failed to set answer for ${viewerId}:`, err);
|
|
233
|
+
this.cleanupViewer(viewerId);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async handleViewerICE(viewerId, candidate) {
|
|
238
|
+
const pc = this.peers.get(viewerId);
|
|
239
|
+
if (!pc) return;
|
|
240
|
+
|
|
241
|
+
if (!pc.remoteDescription) {
|
|
242
|
+
this.pendingICE.get(viewerId)?.push(candidate);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.warn(`Failed to add ICE for ${viewerId}:`, err);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
cleanupViewer(viewerId) {
|
|
254
|
+
const pc = this.peers.get(viewerId);
|
|
255
|
+
if (pc) {
|
|
256
|
+
try {
|
|
257
|
+
pc.close();
|
|
258
|
+
} catch {
|
|
259
|
+
// already closed
|
|
260
|
+
}
|
|
261
|
+
this.peers.delete(viewerId);
|
|
262
|
+
}
|
|
263
|
+
this.pendingICE.delete(viewerId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
cleanupAll() {
|
|
267
|
+
for (const viewerId of this.peers.keys()) {
|
|
268
|
+
this.cleanupViewer(viewerId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (this.canvasStream) {
|
|
272
|
+
this.canvasStream.getTracks().forEach((t) => t.stop());
|
|
273
|
+
this.canvasStream = null;
|
|
274
|
+
}
|
|
275
|
+
// audioStream is owned by kaiPlayer — don't stop its tracks
|
|
276
|
+
this.audioStream = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getStats() {
|
|
280
|
+
return {
|
|
281
|
+
viewerCount: this.peers.size,
|
|
282
|
+
viewers: Array.from(this.peers.entries()).map(([id, pc]) => ({
|
|
283
|
+
id,
|
|
284
|
+
connectionState: pc.connectionState,
|
|
285
|
+
iceConnectionState: pc.iceConnectionState,
|
|
286
|
+
})),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const streamingSender = new StreamingSender();
|
|
292
|
+
export default streamingSender;
|
|
@@ -95,6 +95,9 @@ export class WebRTCManager {
|
|
|
95
95
|
|
|
96
96
|
// Set encoding parameters to maintain resolution
|
|
97
97
|
const setEncodingParams = async () => {
|
|
98
|
+
// Don't run if the sender's PC has been torn down (the retry
|
|
99
|
+
// timers below outlive cleanupSender).
|
|
100
|
+
if (!this.senderPC || this.senderPC.connectionState === 'closed') return;
|
|
98
101
|
try {
|
|
99
102
|
const params = sender.getParameters();
|
|
100
103
|
|
|
@@ -21,6 +21,7 @@ export function PlayerControls({
|
|
|
21
21
|
onPreviousEffect,
|
|
22
22
|
onNextEffect,
|
|
23
23
|
onOpenCanvasWindow,
|
|
24
|
+
onOpenViewer,
|
|
24
25
|
className = '',
|
|
25
26
|
}) {
|
|
26
27
|
const { isPlaying, position = 0, duration = 0 } = playback || {};
|
|
@@ -200,6 +201,17 @@ export function PlayerControls({
|
|
|
200
201
|
</span>
|
|
201
202
|
</button>
|
|
202
203
|
)}
|
|
204
|
+
{onOpenViewer && (
|
|
205
|
+
<button
|
|
206
|
+
onClick={onOpenViewer}
|
|
207
|
+
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition flex items-center justify-center"
|
|
208
|
+
title="Open Browser Viewer"
|
|
209
|
+
>
|
|
210
|
+
<span className="material-icons text-gray-700 dark:text-gray-300 leading-none">
|
|
211
|
+
open_in_new
|
|
212
|
+
</span>
|
|
213
|
+
</button>
|
|
214
|
+
)}
|
|
203
215
|
</div>
|
|
204
216
|
</div>
|
|
205
217
|
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useStreamingSender - browser-viewer broadcast sender
|
|
3
|
+
*
|
|
4
|
+
* Loads streamingSender on mount and binds its signaling listeners to the
|
|
5
|
+
* IPC bridge so that viewer-join/answer/ice/leave events from the embedded
|
|
6
|
+
* web server are handled. The sender stays idle until the first viewer joins.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect } from 'react';
|
|
10
|
+
|
|
11
|
+
export function useStreamingSender() {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
|
|
15
|
+
(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const module = await import('../../renderer/js/streamingSender.js');
|
|
18
|
+
if (cancelled) return;
|
|
19
|
+
module.default.bindSignalingListeners();
|
|
20
|
+
console.log('✅ Streaming sender initialized');
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error('Failed to initialize streaming sender:', err);
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
cancelled = true;
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
}
|
package/src/web/App.jsx
CHANGED
|
@@ -477,6 +477,7 @@ export function App() {
|
|
|
477
477
|
onSeek={handleSeek}
|
|
478
478
|
onPreviousEffect={handleEffectPrevious}
|
|
479
479
|
onNextEffect={handleEffectNext}
|
|
480
|
+
onOpenViewer={() => window.open('/viewer', '_blank', 'noopener')}
|
|
480
481
|
/>
|
|
481
482
|
</div>
|
|
482
483
|
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
|