loukai-app 0.4.3 → 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.
Files changed (30) hide show
  1. package/README.md +9 -3
  2. package/package.json +4 -2
  3. package/scripts/ensure-electron.js +164 -0
  4. package/src/main/handlers/index.js +5 -0
  5. package/src/main/handlers/streamingHandlers.js +70 -0
  6. package/src/main/preload.js +31 -0
  7. package/src/main/webServer.js +77 -1
  8. package/src/renderer/components/AppRoot.jsx +4 -0
  9. package/src/renderer/dist/assets/{kaiPlayer-DSaY7TxC.js → kaiPlayer-BsM-WzYQ.js} +2 -2
  10. package/src/renderer/dist/assets/kaiPlayer-BsM-WzYQ.js.map +1 -0
  11. package/src/renderer/dist/assets/streamingSender-HwIev870.js +2 -0
  12. package/src/renderer/dist/assets/streamingSender-HwIev870.js.map +1 -0
  13. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js +2 -0
  14. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js.map +1 -0
  15. package/src/renderer/dist/renderer.js +15 -15
  16. package/src/renderer/dist/renderer.js.map +1 -1
  17. package/src/renderer/js/kaiPlayer.js +15 -0
  18. package/src/renderer/js/streamingSender.js +292 -0
  19. package/src/renderer/js/webrtcManager.js +3 -0
  20. package/src/shared/components/PlayerControls.jsx +12 -0
  21. package/src/shared/hooks/useStreamingSender.js +30 -0
  22. package/src/web/App.jsx +1 -0
  23. package/src/web/dist/assets/index-DUPLO3h6.js +11 -0
  24. package/src/web/dist/assets/{index-CGbmW1VG.js.map → index-DUPLO3h6.js.map} +1 -1
  25. package/src/web/dist/index.html +1 -1
  26. package/static/viewer.html +421 -0
  27. package/src/renderer/dist/assets/kaiPlayer-DSaY7TxC.js.map +0 -1
  28. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +0 -2
  29. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +0 -1
  30. package/src/web/dist/assets/index-CGbmW1VG.js +0 -11
package/README.md CHANGED
@@ -11,6 +11,12 @@
11
11
 
12
12
  Loukai is a free, open source karaoke software that runs locally on your computer to **play** and **create** karaoke files from your own music. Built on M4A Stems (MPEG-4 multi-track audio), it uses industry-standard formats compatible with DJ software, giving you full control over your personal karaoke library.
13
13
 
14
+ ## Quick start (with nodejs installed)
15
+
16
+ ```
17
+ npx loukai-app
18
+ ```
19
+
14
20
  **Key highlights:**
15
21
  - **Open Format**: Built on NI Stems — no vendor lock-in, works with Traktor, Mixxx, and other DJ software
16
22
  - **Create Your Own**: Built-in Creator processes your audio files into stem-separated karaoke with AI-transcribed lyrics
@@ -24,7 +30,7 @@ Loukai is a free, open source karaoke software that runs locally on your compute
24
30
  ## Features
25
31
 
26
32
  ### Audio & Playback
27
- - **M4A Stems Format (Primary)**: Built on [NI Stems](https://www.native-instruments.com/en/specials/stems/) with karaoke extensions
33
+ - **MP4 Stems Format (Primary)**: Built on [NI Stems](https://www.native-instruments.com/en/specials/stems/) with karaoke extensions
28
34
  - Compatible with DJ software (Traktor, Mixxx) via standard NI Stems metadata
29
35
  - Smaller file sizes than legacy formats
30
36
  - Embedded lyrics with word-level timing in custom atoms
@@ -51,7 +57,7 @@ Loukai is a free, open source karaoke software that runs locally on your compute
51
57
 
52
58
  ### Library & Search
53
59
  - **Fast Library Scanning**: Automatic metadata extraction from thousands of songs
54
- - **M4A Stems Native**: Optimized for MPEG-4 multi-track audio with full metadata support
60
+ - **MP4 Stems Native**: Optimized for MPEG-4 multi-track audio with full metadata support
55
61
  - **Legacy Format Support**: Also reads CDG/MP3 pairs
56
62
  - **Smart Search**: Fuzzy search across titles, artists, and albums
57
63
  - **Alphabet Navigation**: Quick filtering by first letter
@@ -194,7 +200,7 @@ Loukai is built with a multi-process architecture:
194
200
  - **Library Scanner**: Metadata extraction and caching
195
201
  - **Web Server**: Express 5 REST API + Socket.IO
196
202
  - **State Management**: Centralized app state with event emitters
197
- - **File System**: M4A/CDG file parsing and manipulation
203
+ - **File System**: MP4/CDG file parsing and manipulation
198
204
  - **IPC Handlers**: Communication bridge to renderer
199
205
 
200
206
  ### Renderer Process (React)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loukai-app",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Loukai Karaoke - Free and open source karaoke system for playing and creating stem-based karaoke files",
6
6
  "main": "src/main/main.js",
@@ -16,6 +16,7 @@
16
16
  "build:win": "npm run build:all && electron-builder --win && node scripts/rename-artifacts.js",
17
17
  "build:mac": "npm run build:all && electron-builder --mac && node scripts/rename-artifacts.js",
18
18
  "rebuild": "electron-rebuild",
19
+ "postinstall": "node scripts/ensure-electron.js",
19
20
  "test": "vitest",
20
21
  "test:ui": "vitest --ui",
21
22
  "test:run": "vitest run",
@@ -236,6 +237,7 @@
236
237
  "src/",
237
238
  "public/",
238
239
  "bin/",
239
- "static/"
240
+ "static/",
241
+ "scripts/ensure-electron.js"
240
242
  ]
241
243
  }
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ensure the Electron binary is correctly extracted.
4
+ *
5
+ * Electron's own postinstall extracts its prebuilt binary with `extract-zip`
6
+ * (which bundles the unmaintained `yauzl@2.x`). On Node 24 that extractor
7
+ * silently stalls after the first zip entry, so the postinstall exits 0 with a
8
+ * half-written `dist/` (only `LICENSES.chromium.html`, no `path.txt`). Later,
9
+ * `require('electron')` throws "Electron failed to install correctly" and our
10
+ * `bin/loukai.js` reports "Could not find Electron."
11
+ *
12
+ * This runs as loukai-app's own postinstall (after Electron's). If Electron is
13
+ * already installed correctly it is a silent no-op. Otherwise it re-extracts
14
+ * the downloaded zip using the system's archive tool (`unzip` on macOS/Linux,
15
+ * PowerShell `Expand-Archive` on Windows), which is unaffected by the bug, and
16
+ * writes `path.txt` — making `npx loukai-app` reliable on any Node version.
17
+ *
18
+ * Best-effort: never fails the install. If repair is impossible it logs
19
+ * actionable guidance and exits 0.
20
+ */
21
+
22
+ import { createRequire } from 'module';
23
+ import { execFileSync } from 'child_process';
24
+ import {
25
+ existsSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ renameSync,
29
+ rmSync,
30
+ mkdirSync,
31
+ } from 'fs';
32
+ import { join, dirname } from 'path';
33
+
34
+ const require = createRequire(import.meta.url);
35
+
36
+ function log(msg) {
37
+ console.log(`[ensure-electron] ${msg}`);
38
+ }
39
+
40
+ function platformBinaryPath(platform) {
41
+ switch (platform) {
42
+ case 'mas':
43
+ case 'darwin':
44
+ return 'Electron.app/Contents/MacOS/Electron';
45
+ case 'freebsd':
46
+ case 'openbsd':
47
+ case 'linux':
48
+ return 'electron';
49
+ case 'win32':
50
+ return 'electron.exe';
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function isInstalled(electronDir, version, platformPath) {
57
+ try {
58
+ const distVersion = readFileSync(join(electronDir, 'dist', 'version'), 'utf-8').replace(/^v/, '');
59
+ if (distVersion !== version) return false;
60
+ if (readFileSync(join(electronDir, 'path.txt'), 'utf-8') !== platformPath) return false;
61
+ } catch {
62
+ return false;
63
+ }
64
+ return existsSync(join(electronDir, 'dist', platformPath));
65
+ }
66
+
67
+ function extractZip(zipPath, destDir) {
68
+ rmSync(destDir, { recursive: true, force: true });
69
+ mkdirSync(destDir, { recursive: true });
70
+ if (process.platform === 'win32') {
71
+ // PowerShell is present on all supported Windows versions.
72
+ execFileSync(
73
+ 'powershell',
74
+ ['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -Path "${zipPath}" -DestinationPath "${destDir}" -Force`],
75
+ { stdio: 'ignore' }
76
+ );
77
+ } else {
78
+ // `unzip` ships with macOS and virtually every Linux base image.
79
+ execFileSync('unzip', ['-q', '-o', zipPath, '-d', destDir], { stdio: 'ignore' });
80
+ }
81
+ }
82
+
83
+ async function main() {
84
+ if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) {
85
+ return; // user opted out of the binary entirely
86
+ }
87
+
88
+ let electronPkg;
89
+ try {
90
+ electronPkg = require.resolve('electron/package.json');
91
+ } catch {
92
+ return; // electron not installed (e.g. devDeps pruned) — nothing to repair
93
+ }
94
+
95
+ const electronDir = dirname(electronPkg);
96
+ const { version } = require(electronPkg);
97
+ const platform = process.env.npm_config_platform || process.platform;
98
+ const arch = process.env.npm_config_arch || process.arch;
99
+ const platformPath = platformBinaryPath(platform);
100
+
101
+ if (!platformPath) {
102
+ log(`Unsupported platform "${platform}"; leaving Electron untouched.`);
103
+ return;
104
+ }
105
+
106
+ if (isInstalled(electronDir, version, platformPath)) {
107
+ return; // already good — silent no-op (the common case)
108
+ }
109
+
110
+ log(`Electron ${version} is not fully extracted; repairing (Node ${process.version} extract-zip workaround)…`);
111
+
112
+ // Resolve the downloaded artifact zip. @electron/get returns the cached zip
113
+ // path, or downloads it if missing (the download path is unaffected by the
114
+ // extraction bug). Resolve it from Electron's own dependency tree.
115
+ let zipPath;
116
+ try {
117
+ const getRequire = createRequire(electronPkg);
118
+ const { downloadArtifact } = getRequire('@electron/get');
119
+ let checksums;
120
+ try {
121
+ checksums = getRequire(join(electronDir, 'checksums.json'));
122
+ } catch {
123
+ checksums = undefined;
124
+ }
125
+ zipPath = await downloadArtifact({
126
+ version,
127
+ artifactName: 'electron',
128
+ platform,
129
+ arch,
130
+ checksums,
131
+ });
132
+ } catch (err) {
133
+ log(`Could not obtain the Electron zip: ${err.message}`);
134
+ log('Run `npm rebuild electron` (Node 22 or earlier), or reinstall, to fix.');
135
+ return;
136
+ }
137
+
138
+ const distDir = join(electronDir, 'dist');
139
+ try {
140
+ extractZip(zipPath, distDir);
141
+
142
+ // Mirror Electron's install.js: hoist the type defs and write path.txt.
143
+ const srcTypeDef = join(distDir, 'electron.d.ts');
144
+ if (existsSync(srcTypeDef)) {
145
+ renameSync(srcTypeDef, join(electronDir, 'electron.d.ts'));
146
+ }
147
+ writeFileSync(join(electronDir, 'path.txt'), platformPath);
148
+ } catch (err) {
149
+ log(`Extraction failed: ${err.message}`);
150
+ log('Ensure `unzip` (macOS/Linux) or PowerShell (Windows) is available, then reinstall.');
151
+ return;
152
+ }
153
+
154
+ if (isInstalled(electronDir, version, platformPath)) {
155
+ log('Electron repaired successfully.');
156
+ } else {
157
+ log('Repair did not produce a valid install; try `npm rebuild electron`.');
158
+ }
159
+ }
160
+
161
+ main().catch((err) => {
162
+ // Never fail the install over this.
163
+ log(`Unexpected error (ignored): ${err.message}`);
164
+ });
@@ -39,6 +39,8 @@ import { registerAutotuneHandlers } from './autotuneHandlers.js';
39
39
  log('✓ autotuneHandlers');
40
40
  import { registerCreatorHandlers } from './creatorHandlers.js';
41
41
  log('✓ creatorHandlers');
42
+ import { registerStreamingHandlers } from './streamingHandlers.js';
43
+ log('✓ streamingHandlers');
42
44
 
43
45
  /**
44
46
  * Register all IPC handlers
@@ -72,6 +74,9 @@ export function registerAllHandlers(mainApp) {
72
74
  // Creator handlers
73
75
  registerCreatorHandlers(mainApp);
74
76
 
77
+ // Streaming (browser viewer) handlers
78
+ registerStreamingHandlers(mainApp);
79
+
75
80
  log('✅ All IPC handlers registered');
76
81
  } catch (error) {
77
82
  console.error('❌ Failed to register IPC handlers:', error);
@@ -0,0 +1,70 @@
1
+ import { log } from '../logger.js';
2
+ import { ipcMain, shell } from 'electron';
3
+
4
+ /**
5
+ * Streaming IPC Handlers
6
+ *
7
+ * Bridges signaling between the embedded web server's Socket.IO (admin-authed
8
+ * `viewer-clients` room) and the renderer-side StreamingSender. Also exposes a
9
+ * helper for opening the viewer URL in the system browser.
10
+ */
11
+
12
+ /**
13
+ * Register all streaming-related IPC handlers.
14
+ * @param {Object} mainApp - Main application instance
15
+ */
16
+ export function registerStreamingHandlers(mainApp) {
17
+ // Compute the URL the admin should open to view the stream.
18
+ const getViewerUrl = () => {
19
+ const port = mainApp.webServer?.port;
20
+ if (!port) return null;
21
+ // Use loopback by default; admin can manually substitute the LAN IP when
22
+ // pointing a TV/phone browser at this URL.
23
+ return `http://localhost:${port}/viewer`;
24
+ };
25
+
26
+ ipcMain.handle('streaming:getViewerUrl', () => {
27
+ return { url: getViewerUrl() };
28
+ });
29
+
30
+ ipcMain.handle('streaming:openViewer', async () => {
31
+ const url = getViewerUrl();
32
+ if (!url) {
33
+ return { success: false, error: 'Web server not running' };
34
+ }
35
+ try {
36
+ await shell.openExternal(url);
37
+ return { success: true, url };
38
+ } catch (err) {
39
+ log('Failed to open viewer URL:', err);
40
+ return { success: false, error: err.message };
41
+ }
42
+ });
43
+
44
+ // Renderer → web server: forward an offer to a specific viewer socket
45
+ ipcMain.handle('streaming:sendViewerOffer', (_event, { viewerId, offer }) => {
46
+ mainApp.webServer?.sendToViewer?.(viewerId, 'viewer:offer', { offer });
47
+ });
48
+
49
+ // Renderer → web server: forward an ICE candidate to a specific viewer socket
50
+ ipcMain.handle('streaming:sendViewerICE', (_event, { viewerId, candidate }) => {
51
+ mainApp.webServer?.sendToViewer?.(viewerId, 'viewer:ice', { candidate });
52
+ });
53
+
54
+ // Stats for debugging
55
+ ipcMain.handle('streaming:getStats', () => {
56
+ const count = mainApp.webServer?.getViewerCount?.() ?? 0;
57
+ return { viewerCount: count };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Forward signaling events from the web server (called from webServer.js
63
+ * when a viewer socket sends an event) to the main renderer where the
64
+ * StreamingSender lives.
65
+ */
66
+ export function forwardViewerEvent(mainApp, event, payload) {
67
+ if (mainApp.mainWindow && !mainApp.mainWindow.isDestroyed()) {
68
+ mainApp.mainWindow.webContents.send(`streaming:${event}`, payload);
69
+ }
70
+ }
@@ -96,6 +96,31 @@ const api = {
96
96
  sendFrame: (dataUrl) => ipcRenderer.invoke('canvas:sendFrame', dataUrl),
97
97
  },
98
98
 
99
+ streaming: {
100
+ // Open the browser viewer URL via the system browser
101
+ openViewer: () => ipcRenderer.invoke('streaming:openViewer'),
102
+ // Get the viewer URL (for showing in UI without opening)
103
+ getViewerUrl: () => ipcRenderer.invoke('streaming:getViewerUrl'),
104
+
105
+ // Outbound signaling to viewers (sent through the web server's Socket.IO)
106
+ sendViewerOffer: ({ viewerId, offer }) =>
107
+ ipcRenderer.invoke('streaming:sendViewerOffer', { viewerId, offer }),
108
+ sendViewerICE: ({ viewerId, candidate }) =>
109
+ ipcRenderer.invoke('streaming:sendViewerICE', { viewerId, candidate }),
110
+
111
+ // Inbound signaling from viewers (forwarded from the web server)
112
+ onViewerJoin: (callback) =>
113
+ ipcRenderer.on('streaming:viewerJoin', (_e, payload) => callback(payload)),
114
+ onViewerAnswer: (callback) =>
115
+ ipcRenderer.on('streaming:viewerAnswer', (_e, payload) => callback(payload)),
116
+ onViewerICE: (callback) =>
117
+ ipcRenderer.on('streaming:viewerICE', (_e, payload) => callback(payload)),
118
+ onViewerLeave: (callback) =>
119
+ ipcRenderer.on('streaming:viewerLeave', (_e, payload) => callback(payload)),
120
+
121
+ getStats: () => ipcRenderer.invoke('streaming:getStats'),
122
+ },
123
+
99
124
  library: {
100
125
  getSongsFolder: () => ipcRenderer.invoke('library:getSongsFolder'),
101
126
  setSongsFolder: () => ipcRenderer.invoke('library:setSongsFolder'),
@@ -188,10 +213,16 @@ const api = {
188
213
  sendWebRTCResponse: (command, result) => {
189
214
  // SECURITY FIX (#24): Whitelist allowed WebRTC commands to prevent IPC channel injection
190
215
  const ALLOWED_COMMANDS = [
216
+ // Receiver side (canvas window)
191
217
  'setupReceiver',
192
218
  'checkReceiverReady',
193
219
  'setOfferAndCreateAnswer',
194
220
  'getReceiverStatus',
221
+ // Sender side (main window)
222
+ 'setupSender',
223
+ 'createOffer',
224
+ 'setAnswer',
225
+ 'getSenderStatus',
195
226
  ];
196
227
  if (!ALLOWED_COMMANDS.includes(command)) {
197
228
  console.warn('Blocked invalid WebRTC command:', command);
@@ -7,6 +7,7 @@ import os from 'os';
7
7
  import crypto from 'crypto';
8
8
  import bcrypt from 'bcryptjs';
9
9
  import cookieSession from 'cookie-session';
10
+ import Keygrip from 'keygrip';
10
11
  import { Server } from 'socket.io';
11
12
  import http from 'http';
12
13
  import rateLimit from 'express-rate-limit';
@@ -24,6 +25,7 @@ import { getSetting } from '../shared/services/settingsService.js';
24
25
  import * as serverSettingsService from '../shared/services/serverSettingsService.js';
25
26
  import * as creatorService from '../shared/services/creatorService.js';
26
27
  import { validateSongPath, validateBase64Path } from './utils/pathValidator.js';
28
+ import { forwardViewerEvent } from './handlers/streamingHandlers.js';
27
29
 
28
30
  // ESM equivalent of __dirname
29
31
  const __filename = fileURLToPath(import.meta.url);
@@ -54,10 +56,30 @@ class WebServer {
54
56
  this.songPathToId = new Map();
55
57
  this.songIdToPath = new Map();
56
58
 
59
+ // Viewer sockets keyed by viewerId for WebRTC signaling
60
+ this.viewerSockets = new Map();
61
+
57
62
  this.setupMiddleware();
58
63
  this.setupRoutes();
59
64
  }
60
65
 
66
+ /**
67
+ * Send a Socket.IO event to a specific viewer by id.
68
+ * Called from streamingHandlers when the renderer-side sender produces
69
+ * an offer or ICE candidate for that viewer.
70
+ */
71
+ sendToViewer(viewerId, event, payload) {
72
+ const socket = this.viewerSockets.get(viewerId);
73
+ if (socket) {
74
+ socket.emit(event, payload);
75
+ }
76
+ }
77
+
78
+ /** Number of currently connected, authenticated viewer sockets. */
79
+ getViewerCount() {
80
+ return this.viewerSockets.size;
81
+ }
82
+
61
83
  /**
62
84
  * Generate an opaque ID for a song path (security: don't expose file paths)
63
85
  * Uses a deterministic hash so the same path always gets the same ID
@@ -313,6 +335,20 @@ class WebServer {
313
335
  }
314
336
  });
315
337
 
338
+ // Browser viewer page — admin-auth gated. Unlike requireAuth (which is
339
+ // for API endpoints and returns JSON 401), redirect unauthenticated
340
+ // browsers to the admin login UI so the user gets a sensible flow.
341
+ const requireAuthOrRedirect = (req, res, next) => {
342
+ if (req.session && req.session.isAdmin) {
343
+ next();
344
+ } else {
345
+ res.redirect('/admin');
346
+ }
347
+ };
348
+ this.app.get('/viewer', requireAuthOrRedirect, (req, res) => {
349
+ res.sendFile(path.join(__dirname, '../../static/viewer.html'));
350
+ });
351
+
316
352
  // Get available letters for alphabet navigation
317
353
  this.app.get('/api/letters', async (req, res) => {
318
354
  try {
@@ -2025,6 +2061,23 @@ class WebServer {
2025
2061
  socket.join('electron-apps');
2026
2062
  } else if (data.type === 'web-ui') {
2027
2063
  socket.join('web-clients');
2064
+ } else if (data.type === 'viewer') {
2065
+ // Viewer (browser tab opened from "Open Viewer" admin action) — auth-gated.
2066
+ const session = this.validateSocketSession(socket);
2067
+ if (!session || !session.isAdmin) {
2068
+ console.warn('⚠️ Unauthorized viewer connection attempt:', socket.id);
2069
+ socket.emit('auth-error', { message: 'Admin authentication required' });
2070
+ return;
2071
+ }
2072
+ const viewerId = socket.id;
2073
+ socket.viewerId = viewerId;
2074
+ socket.join('viewer-clients');
2075
+ this.viewerSockets.set(viewerId, socket);
2076
+ log(`🎥 Viewer connected: ${viewerId}`);
2077
+ socket.emit('viewer:ready');
2078
+ // Tell the renderer-side StreamingSender to build a peer connection
2079
+ // and send an offer back through us for this viewer.
2080
+ forwardViewerEvent(this.mainApp, 'viewerJoin', { viewerId });
2028
2081
  } else if (data.type === 'admin') {
2029
2082
  // SECURITY FIX (#22): Validate admin session before allowing admin room access
2030
2083
  const session = this.validateSocketSession(socket);
@@ -2062,9 +2115,33 @@ class WebServer {
2062
2115
  }
2063
2116
  });
2064
2117
 
2118
+ // Viewer signaling (answer + ICE candidates from the browser viewer)
2119
+ socket.on('viewer:answer', ({ answer }) => {
2120
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2121
+ forwardViewerEvent(this.mainApp, 'viewerAnswer', {
2122
+ viewerId: socket.viewerId,
2123
+ answer,
2124
+ });
2125
+ }
2126
+ });
2127
+
2128
+ socket.on('viewer:ice', ({ candidate }) => {
2129
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2130
+ forwardViewerEvent(this.mainApp, 'viewerICE', {
2131
+ viewerId: socket.viewerId,
2132
+ candidate,
2133
+ });
2134
+ }
2135
+ });
2136
+
2065
2137
  // Handle disconnection
2066
2138
  socket.on('disconnect', () => {
2067
2139
  log('Client disconnected:', socket.id);
2140
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2141
+ this.viewerSockets.delete(socket.viewerId);
2142
+ forwardViewerEvent(this.mainApp, 'viewerLeave', { viewerId: socket.viewerId });
2143
+ log(`🎥 Viewer disconnected: ${socket.viewerId}`);
2144
+ }
2068
2145
  });
2069
2146
 
2070
2147
  // Song request events
@@ -2520,7 +2597,6 @@ class WebServer {
2520
2597
  }
2521
2598
 
2522
2599
  // Verify signature using Keygrip (same mechanism as cookie-session)
2523
- const Keygrip = require('keygrip');
2524
2600
  const keys = new Keygrip([this.getOrCreateSecretKey()]);
2525
2601
 
2526
2602
  if (!keys.verify('kai-admin-session=' + sessionCookie, sigCookie)) {
@@ -10,6 +10,7 @@ import { SettingsProvider } from '../../shared/contexts/SettingsContext.jsx';
10
10
  import { useAudioEngine } from '../../shared/hooks/useAudioEngine.js';
11
11
  import { useSettingsPersistence } from '../../shared/hooks/useSettingsPersistence.js';
12
12
  import { useWebRTC } from '../../shared/hooks/useWebRTC.js';
13
+ import { useStreamingSender } from '../../shared/hooks/useStreamingSender.js';
13
14
 
14
15
  function AppInitializer({ children }) {
15
16
  // Initialize audio engine
@@ -21,6 +22,9 @@ function AppInitializer({ children }) {
21
22
  // Initialize WebRTC
22
23
  useWebRTC();
23
24
 
25
+ // Initialize streaming sender (broadcasts canvas to /viewer browser tabs)
26
+ useStreamingSender();
27
+
24
28
  return <>{children}</>;
25
29
  }
26
30
 
@@ -1,2 +1,2 @@
1
- import{P as d,M as h}from"./microphoneEngine-BaCUhhQc.js";class f extends d{constructor(){super(),this.audioContexts={PA:null,IEM:null},this.outputDevices={PA:"default",IEM:"default"},this.currentPosition=0,this.songData=null,this.audioBuffers=new Map,this.outputNodes={PA:{sourceNodes:new Map,gainNodes:new Map,masterGain:null,analyser:null,vocalsPAGain:null},IEM:{sourceNodes:new Map,gainNodes:new Map,masterGain:null}},this.vocalsPAEnabled=!1,this.startTime=0,this.pauseTime=0,this.micEngine=null,this.mixerState={PA:{gain:0,muted:!1},IEM:{gain:0,muted:!0,mono:!0},mic:{gain:0,muted:!0},stems:[],micToSpeakers:!0,enableMic:!0}}async initialize(){try{const t=await this.getAvailableOutputDevices();await this.loadDevicePreferences(t);const i={};this.outputDevices.PA!=="default"&&"sinkId"in AudioContext.prototype&&(i.sinkId=this.outputDevices.PA),this.audioContexts.PA=new(window.AudioContext||window.webkitAudioContext)(i),this.outputNodes.PA.masterGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.masterGain.connect(this.audioContexts.PA.destination);const e=this.mixerState.PA.muted?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.value=e,this.outputNodes.PA.analyser=this.audioContexts.PA.createAnalyser(),this.outputNodes.PA.analyser.fftSize=2048,this.outputNodes.PA.analyser.smoothingTimeConstant=.8,this.outputNodes.PA.vocalsPAGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.vocalsPAGain.gain.value=0,this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain);const s={};this.outputDevices.IEM!=="default"&&"sinkId"in AudioContext.prototype&&(s.sinkId=this.outputDevices.IEM),this.audioContexts.IEM=new(window.AudioContext||window.webkitAudioContext)(s),this.outputNodes.IEM.masterGain=this.audioContexts.IEM.createGain(),this.outputNodes.IEM.masterGain.connect(this.audioContexts.IEM.destination);const n=this.mixerState.IEM.muted?0:this.dbToLinear(this.mixerState.IEM.gain);if(this.outputNodes.IEM.masterGain.gain.value=n,this.micEngine=new h(this.audioContexts.PA,this.outputNodes.PA.masterGain,{getCurrentPosition:()=>this.getCurrentPosition()}),await this.micEngine.loadAutoTuneWorklet(),await this.loadMicSettings(),this.micEngine.enableMic){await this.micEngine.startMicrophoneInput(this.micEngine.inputDevice);const a=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(a)}return!0}catch(t){return console.warn("⚠️ Audio initialization issue:",t.message),!1}}resolveDeviceByLabel(t,i,e){if(!i||!i.id&&!i.name||i.deviceKind==="default"||i.id==="")return"default";if(i.name){const s=t.find(n=>n.label===i.name);if(s)return s.deviceId!==i.id&&console.log(`🎧 ${e}: Matched "${i.name}" by label (ID changed)`),s.deviceId}if(i.id){const s=t.find(n=>n.deviceId===i.id);if(s)return s.deviceId}return console.warn(`🎧 ${e}: Saved device "${i.name||i.id}" not found, using default`),"default"}async loadDevicePreferences(t=[]){try{if(window.kaiAPI.settings){const i=await window.kaiAPI.settings.get("devicePreferences",null);if(i?.PA&&(this.outputDevices.PA=this.resolveDeviceByLabel(t,i.PA,"PA")),i?.IEM&&(this.outputDevices.IEM=this.resolveDeviceByLabel(t,i.IEM,"IEM")),i?.input){const e=await this.getAvailableInputDevices();this.inputDevice=this.resolveDeviceByLabel(e,i.input,"input")}}if(window.kaiAPI?.app){const i=await window.kaiAPI.app.getState();i?.mixer&&(typeof i.mixer.PA?.gain=="number"&&(this.mixerState.PA.gain=i.mixer.PA.gain),typeof i.mixer.PA?.muted=="boolean"&&(this.mixerState.PA.muted=i.mixer.PA.muted),typeof i.mixer.IEM?.gain=="number"&&(this.mixerState.IEM.gain=i.mixer.IEM.gain),typeof i.mixer.IEM?.muted=="boolean"&&(this.mixerState.IEM.muted=i.mixer.IEM.muted),typeof i.mixer.mic?.gain=="number"&&(this.mixerState.mic.gain=i.mixer.mic.gain),typeof i.mixer.mic?.muted=="boolean"&&(this.mixerState.mic.muted=i.mixer.mic.muted))}}catch(i){console.warn("Failed to load device preferences:",i.message)}}async getAvailableOutputDevices(){try{return(await navigator.mediaDevices.enumerateDevices()).filter(i=>i.kind==="audiooutput")}catch(t){return console.warn("Failed to enumerate output devices:",t.message),[]}}async getAvailableInputDevices(){try{return(await navigator.mediaDevices.enumerateDevices()).filter(i=>i.kind==="audioinput")}catch(t){return console.warn("Failed to enumerate input devices:",t.message),[]}}async setOutputDevice(t,i){try{if(!["PA","IEM"].includes(t))return console.error("Invalid bus type:",t),!1;const e=this.isPlaying;e&&this.pause();const s=this.currentPosition;this.outputDevices[t]=i,this.audioContexts[t]&&await this.audioContexts[t].close();const n={};i!=="default"&&"sinkId"in AudioContext.prototype&&(n.sinkId=i),this.audioContexts[t]=new(window.AudioContext||window.webkitAudioContext)(n),this.outputNodes[t].masterGain=this.audioContexts[t].createGain(),this.outputNodes[t].masterGain.connect(this.audioContexts[t].destination);const a=this.mixerState[t].muted?0:this.dbToLinear(this.mixerState[t].gain);if(this.outputNodes[t].masterGain.gain.value=a,t==="PA"&&(this.outputNodes.PA.analyser=this.audioContexts.PA.createAnalyser(),this.outputNodes.PA.analyser.fftSize=2048,this.outputNodes.PA.analyser.smoothingTimeConstant=.8,this.outputNodes.PA.vocalsPAGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.vocalsPAGain.gain.value=this.vocalsPAEnabled?this.dbToLinear(this.mixerState.IEM.gain):0,this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain)),this.outputNodes[t].sourceNodes.clear(),this.outputNodes[t].gainNodes.clear(),t==="PA"&&this.micEngine&&(this.micEngine.updateAudioContext(this.audioContexts.PA,this.outputNodes.PA.masterGain),await this.micEngine.loadAutoTuneWorklet(),this.micEngine.enableMic)){await this.micEngine.startMicrophoneInput(this.micEngine.inputDevice);const c=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(c)}return this.songData&&await this.reloadAudioBuffersForBus(t),e&&this.songData&&(this.currentPosition=s,await this.play()),!0}catch(e){return console.error(`Failed to set ${t} output device:`,e),!1}}async loadSong(t){return this.songData=t,this.resetPosition(),this.currentPosition=0,this.startTime=0,this.pauseTime=0,this.monitoringStartTime=null,this.stopSongEndMonitoring(),this.mixerState.stems=(t.audio?.sources||[]).map((i,e)=>({id:i.name||i.filename,name:i.name||i.filename,gain:i.gain||0,index:e})),await this.loadAudioBuffers(t),this.micEngine&&this.micEngine.clearPitchReference(),this.reportSongLoaded(),this.reportStateChange(),!0}reportSongLoaded(){if(window.kaiAPI?.renderer&&this.songData){const t=this.getDuration();window.kaiAPI.renderer.songLoaded({path:this.songData.originalFilePath||this.songData.filePath,title:this.songData.metadata?.title||"Unknown",artist:this.songData.metadata?.artist||"Unknown",duration:t,isLoading:!1,format:"kai"}),this.reportMixerState()}}reportMixerState(){window.kaiAPI?.renderer&&this.mixerState&&window.kaiAPI.renderer.updateMixerState(this.mixerState)}async loadAudioBuffers(t){if(!t.audio?.sources){console.warn("No audio sources found in song data");return}(!this.audioContexts.PA||!this.audioContexts.IEM)&&await this.initialize();for(const e of t.audio.sources)try{if(e.audioData&&e.audioData.length>0){const s=e.audioData.buffer.slice(e.audioData.byteOffset,e.audioData.byteOffset+e.audioData.byteLength),n=await this.audioContexts.PA.decodeAudioData(s);this.audioBuffers.set(e.name,n)}else console.warn(`No audio data for source: ${e.name}`)}catch(s){console.error(`Failed to decode audio for ${e.name}:`,s)}let i=0;for(const[e,s]of this.audioBuffers)s.duration>i&&(i=s.duration);i>0?(this.songData.metadata||(this.songData.metadata={}),this.songData.metadata.duration=i):console.warn("No audio buffers loaded, duration remains 0")}async reloadAudioBuffersForBus(t){if(!(!this.songData?.audio?.sources||!this.audioContexts[t])){for(const i of this.songData.audio.sources)if(i.audioData&&i.audioData.length>0)try{if(!this.audioBuffers.has(i.name)){const e=i.audioData.buffer.slice(i.audioData.byteOffset,i.audioData.byteOffset+i.audioData.byteLength),s=await this.audioContexts[t].decodeAudioData(e);this.audioBuffers.set(i.name,s)}}catch(e){console.error(`Failed to reload audio buffer for ${t} - ${i.name}:`,e)}}}async play(){return this.songData?!this.audioContexts.PA||!this.audioContexts.IEM?(console.error("Audio contexts not initialized"),!1):(this.audioContexts.PA.state==="suspended"&&await this.audioContexts.PA.resume(),this.audioContexts.IEM.state==="suspended"&&await this.audioContexts.IEM.resume(),this.isPlaying=!0,this.stopAllSources(),this.createAudioGraph(),this.startAudioSources(),this.startSongEndMonitoring(),this.startStateReporting(),this.micEngine&&this.micEngine.setPlaying(!0),this.reportStateChange(),!0):(console.error("No song loaded"),!1)}pause(){return this.currentPosition=this.getCurrentPosition(),this.isPlaying=!1,this.audioContexts.PA&&(this.pauseTime=this.audioContexts.PA.currentTime),this.stopAllSources(),this.stopSongEndMonitoring(),this.stopStateReporting(),this.micEngine&&this.micEngine.setPlaying(!1),this.reportStateChange(),!0}seek(t){return this.currentPosition=t,this.isPlaying&&(this.stopAllSources(),this.startAudioSources()),this.reportStateChange(),!0}stopAllSources(){this.outputNodes.PA.sourceNodes.size+this.outputNodes.IEM.sourceNodes.size,this.outputNodes.PA.sourceNodes.forEach((t,i)=>{try{t.stop(),t.disconnect()}catch{}}),this.outputNodes.PA.sourceNodes.clear(),this.outputNodes.IEM.sourceNodes.forEach((t,i)=>{try{t.stop(),t.disconnect()}catch{}}),this.outputNodes.IEM.sourceNodes.clear()}createAudioGraph(){!this.audioContexts.PA||!this.audioContexts.IEM||(this.outputNodes.PA.gainNodes.clear(),this.outputNodes.IEM.gainNodes.clear(),this.mixerState.stems.forEach(t=>{const i=this.audioContexts.PA.createGain();i.connect(this.outputNodes.PA.masterGain),this.outputNodes.PA.gainNodes.set(t.name,i);const e=this.audioContexts.IEM.createGain();if(this.isVocalStem(t.name)&&this.mixerState.iemMonoVocals){const s=this.audioContexts.IEM.createChannelMerger(1);e.connect(s),s.connect(this.outputNodes.IEM.masterGain)}else e.connect(this.outputNodes.IEM.masterGain);this.outputNodes.IEM.gainNodes.set(t.name,e),this.updateStemGain(t)}))}startAudioSources(){if(!this.audioContexts.PA||!this.audioContexts.IEM)return;const t=.1,i=this.audioContexts.PA.currentTime+t,e=this.audioContexts.IEM.currentTime+t;this.startTime=i,this.mixerState.stems.forEach(s=>{if(this.isMixdownStem(s.name)){console.log(`⏭️ Skipping mixdown stem: ${s.name}`);return}const n=this.audioBuffers.get(s.name),a=this.outputNodes.PA.gainNodes.get(s.name),c=this.outputNodes.IEM.gainNodes.get(s.name);if(n&&a&&c)try{const r=Math.min(this.currentPosition,n.duration);if(this.isVocalStem(s.name)){const o=this.audioContexts.IEM.createBufferSource();if(o.buffer=n,o.connect(c),o.start(e,r),this.outputNodes.IEM.sourceNodes.set(s.name,o),this.outputNodes.PA.vocalsPAGain){const u=this.audioContexts.PA.createBufferSource();u.buffer=n,u.connect(this.outputNodes.PA.vocalsPAGain),this.micEngine&&this.micEngine.connectMusicSource(u),u.start(i,r),this.outputNodes.PA.sourceNodes.set(s.name+"_vocalsPA",u)}}else{const o=this.audioContexts.PA.createBufferSource();o.buffer=n,o.connect(a),this.outputNodes.PA.analyser&&o.connect(this.outputNodes.PA.analyser),this.isMelodicStem(s.name)&&this.micEngine&&this.micEngine.connectMusicSource(o),o.start(i,r),this.outputNodes.PA.sourceNodes.set(s.name,o),o.onended=()=>{this.isPlaying&&setTimeout(()=>this.checkForSongEnd(),10)}}}catch(r){console.error(`Failed to start source for ${s.name}:`,r)}else console.warn(`No audio buffer or gain nodes for stem: ${s.name}`)})}isVocalStem(t){const i=["vocals","vocal","voice","lead","singing","vox"],e=t.toLowerCase();return i.some(s=>e.includes(s))}isMixdownStem(t){const i=["mixdown","mix","master","full mix","stereo mix"],e=t.toLowerCase();return i.some(s=>e===s||e.includes(`_${s}`)||e.includes(`${s}_`))}isMelodicStem(t){const i=t.toLowerCase();return i.includes("other")||i.includes("music")||i.includes("instrumental")||i.includes("accompaniment")||i.includes("melody")?!0:!(this.isVocalStem(t)||i.includes("drum")||i.includes("percussion")||i.includes("bass"))}updateStemGain(t){const i=this.outputNodes.PA.gainNodes.get(t.name),e=this.outputNodes.IEM.gainNodes.get(t.name),s=this.isVocalStem(t.name);if(!this.audioContexts.PA||!this.audioContexts.IEM)return;const n=Math.pow(10,t.gain/20);s&&e?e.gain.setValueAtTime(n,this.audioContexts.IEM.currentTime):!s&&i&&i.gain.setValueAtTime(n,this.audioContexts.PA.currentTime)}setMasterGain(t,i){if(!["PA","IEM","mic"].includes(t))return!1;if(this.mixerState[t].gain=i,t==="PA"&&this.outputNodes.PA.masterGain){const e=this.dbToLinear(i);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=this.dbToLinear(i);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=this.mixerState.mic.muted?0:this.dbToLinear(i);this.micEngine.setMicrophoneGain(e)}return this.reportMixerState(),!0}toggleMasterMute(t){if(!["PA","IEM","mic"].includes(t))return!1;this.mixerState[t].muted=!this.mixerState[t].muted;const i=this.mixerState[t].muted;if(t==="PA"&&this.outputNodes.PA.masterGain){const e=i?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=i?0:this.dbToLinear(this.mixerState.IEM.gain);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=i?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(e)}return this.reportMixerState(),!0}setMasterMute(t,i){if(!["PA","IEM","mic"].includes(t))return!1;if(this.mixerState[t].muted=i,t==="PA"&&this.outputNodes.PA.masterGain){const e=i?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=i?0:this.dbToLinear(this.mixerState.IEM.gain);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=i?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(e)}return!0}setVocalsPAEnabled(t,i=.05){if(!this.outputNodes.PA.vocalsPAGain||!this.audioContexts.PA||this.vocalsPAEnabled===t)return;this.vocalsPAEnabled=t;const e=t?this.dbToLinear(this.mixerState.IEM.gain):0,s=this.audioContexts.PA.currentTime;this.outputNodes.PA.vocalsPAGain.gain.cancelScheduledValues(s),this.outputNodes.PA.vocalsPAGain.gain.linearRampToValueAtTime(e,s+i)}getCurrentPosition(){if(this.isPlaying&&this.audioContexts.PA&&this.startTime>0){const t=this.audioContexts.PA.currentTime-this.startTime,i=this.currentPosition+t,e=this.getDuration();return e>0?Math.min(i,e):i}return this.currentPosition}getCurrentTime(){return this.getCurrentPosition()}getDuration(){return this.songData?.metadata?.duration||0}getMixerState(){return{PA:this.mixerState.PA,IEM:this.mixerState.IEM,mic:this.mixerState.mic,stems:this.mixerState.stems,isPlaying:this.isPlaying,position:this.getCurrentPosition(),duration:this.getDuration()}}async startMicrophoneInput(t="default"){this.micEngine&&await this.micEngine.startMicrophoneInput(t)}stopMicrophoneInput(){this.micEngine&&this.micEngine.stopMicrophoneInput()}setAutoTuneSettings(t){if(this.micEngine&&(this.micEngine.setAutoTuneSettings(t),this.micEngine.microphoneGain&&Object.hasOwn(t,"enabled"))){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}setMicrophoneGain(t){this.micEngine&&this.micEngine.setMicrophoneGain(t)}setIEMMonoVocals(t){return this.mixerState.iemMonoVocals=t,this.isPlaying?(this.stopAllSources(),this.createAudioGraph(),this.startAudioSources()):this.createAudioGraph(),!0}async loadMicSettings(){try{if(window.kaiAPI.settings&&this.micEngine){const t=await window.kaiAPI.settings.get("micToSpeakers",!1),i=await window.kaiAPI.settings.get("enableMic",!0),e=await window.kaiAPI.settings.get("iemMonoVocals",!0);this.mixerState.micToSpeakers=t,this.mixerState.enableMic=i,this.mixerState.iemMonoVocals=e,this.micEngine.micToSpeakers=t,this.micEngine.enableMic=i;const s=await window.kaiAPI.settings.get("autoTunePreferences",{});if(s.enabled!==void 0&&(this.micEngine.autotuneSettings.enabled=s.enabled),s.strength!==void 0&&(this.micEngine.autotuneSettings.strength=s.strength),s.speed!==void 0&&(this.micEngine.autotuneSettings.speed=s.speed),s.preferVocals!==void 0&&(this.micEngine.autotuneSettings.preferVocals=s.preferVocals),this.micEngine.autotuneSettings.enabled&&this.micEngine.microphoneGain&&this.micEngine.autoTuneWorkletsLoaded){this.micEngine.enableAutoTune();const n=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(n)}}}catch(t){console.error("Failed to load mic/autotune settings:",t)}}setMicToSpeakers(t){if(this.mixerState.micToSpeakers=t,this.micEngine&&(this.micEngine.setMicToSpeakers(t),this.micEngine.microphoneGain)){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}async setEnableMic(t){if(this.mixerState.enableMic=t,this.micEngine&&(await this.micEngine.setEnableMic(t),t&&this.micEngine.microphoneGain)){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}stop(){this.isPlaying=!1,this.stopAllSources(),this.stopMicrophoneInput(),this.stopSongEndMonitoring(),this.audioContexts.PA&&(this.audioContexts.PA.close(),this.audioContexts.PA=null),this.audioContexts.IEM&&(this.audioContexts.IEM.close(),this.audioContexts.IEM=null),this.audioBuffers.clear(),this.outputNodes.PA.gainNodes.clear(),this.outputNodes.IEM.gainNodes.clear()}async reinitialize(){this.stop(),await new Promise(t=>setTimeout(t,200)),await this.initialize()}setOnSongEndedCallback(t){this.onSongEndedCallback=t}checkForSongEnd(){const t=this.getDuration(),i=this.getCurrentPosition(),e=this.monitoringStartTime?this.audioContexts.PA.currentTime-this.monitoringStartTime:0;this.isPlaying&&e>2&&t>3&&i>=t-.2&&(this.stopAllSources(),this.stopSongEndMonitoring(),this.pause(),this._triggerSongEnd())}startSongEndMonitoring(){this.songEndMonitor&&clearInterval(this.songEndMonitor),this.monitoringStartTime=this.audioContexts.PA.currentTime,this.songEndMonitor=setInterval(()=>{this.checkForSongEnd()},250)}stopSongEndMonitoring(){this.songEndMonitor&&(clearInterval(this.songEndMonitor),this.songEndMonitor=null)}dbToLinear(t){return Math.pow(10,t/20)}linearToDb(t){return 20*Math.log10(t)}getFormat(){return"kai"}}export{f as KAIPlayer};
2
- //# sourceMappingURL=kaiPlayer-DSaY7TxC.js.map
1
+ import{P as d,M as h}from"./microphoneEngine-BaCUhhQc.js";class f extends d{constructor(){super(),this.audioContexts={PA:null,IEM:null},this.outputDevices={PA:"default",IEM:"default"},this.currentPosition=0,this.songData=null,this.audioBuffers=new Map,this.outputNodes={PA:{sourceNodes:new Map,gainNodes:new Map,masterGain:null,analyser:null,vocalsPAGain:null,streamDestination:null},IEM:{sourceNodes:new Map,gainNodes:new Map,masterGain:null}},this.vocalsPAEnabled=!1,this.startTime=0,this.pauseTime=0,this.micEngine=null,this.mixerState={PA:{gain:0,muted:!1},IEM:{gain:0,muted:!0,mono:!0},mic:{gain:0,muted:!0},stems:[],micToSpeakers:!0,enableMic:!0}}async initialize(){try{const t=await this.getAvailableOutputDevices();await this.loadDevicePreferences(t);const i={};this.outputDevices.PA!=="default"&&"sinkId"in AudioContext.prototype&&(i.sinkId=this.outputDevices.PA),this.audioContexts.PA=new(window.AudioContext||window.webkitAudioContext)(i),this.outputNodes.PA.masterGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.masterGain.connect(this.audioContexts.PA.destination);const e=this.mixerState.PA.muted?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.value=e,this.outputNodes.PA.analyser=this.audioContexts.PA.createAnalyser(),this.outputNodes.PA.analyser.fftSize=2048,this.outputNodes.PA.analyser.smoothingTimeConstant=.8,this.outputNodes.PA.vocalsPAGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.vocalsPAGain.gain.value=0,this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain),this.outputNodes.PA.streamDestination=this.audioContexts.PA.createMediaStreamDestination(),this.outputNodes.PA.masterGain.connect(this.outputNodes.PA.streamDestination);const s={};this.outputDevices.IEM!=="default"&&"sinkId"in AudioContext.prototype&&(s.sinkId=this.outputDevices.IEM),this.audioContexts.IEM=new(window.AudioContext||window.webkitAudioContext)(s),this.outputNodes.IEM.masterGain=this.audioContexts.IEM.createGain(),this.outputNodes.IEM.masterGain.connect(this.audioContexts.IEM.destination);const n=this.mixerState.IEM.muted?0:this.dbToLinear(this.mixerState.IEM.gain);if(this.outputNodes.IEM.masterGain.gain.value=n,this.micEngine=new h(this.audioContexts.PA,this.outputNodes.PA.masterGain,{getCurrentPosition:()=>this.getCurrentPosition()}),await this.micEngine.loadAutoTuneWorklet(),await this.loadMicSettings(),this.micEngine.enableMic){await this.micEngine.startMicrophoneInput(this.micEngine.inputDevice);const a=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(a)}return!0}catch(t){return console.warn("⚠️ Audio initialization issue:",t.message),!1}}resolveDeviceByLabel(t,i,e){if(!i||!i.id&&!i.name||i.deviceKind==="default"||i.id==="")return"default";if(i.name){const s=t.find(n=>n.label===i.name);if(s)return s.deviceId!==i.id&&console.log(`🎧 ${e}: Matched "${i.name}" by label (ID changed)`),s.deviceId}if(i.id){const s=t.find(n=>n.deviceId===i.id);if(s)return s.deviceId}return console.warn(`🎧 ${e}: Saved device "${i.name||i.id}" not found, using default`),"default"}async loadDevicePreferences(t=[]){try{if(window.kaiAPI.settings){const i=await window.kaiAPI.settings.get("devicePreferences",null);if(i?.PA&&(this.outputDevices.PA=this.resolveDeviceByLabel(t,i.PA,"PA")),i?.IEM&&(this.outputDevices.IEM=this.resolveDeviceByLabel(t,i.IEM,"IEM")),i?.input){const e=await this.getAvailableInputDevices();this.inputDevice=this.resolveDeviceByLabel(e,i.input,"input")}}if(window.kaiAPI?.app){const i=await window.kaiAPI.app.getState();i?.mixer&&(typeof i.mixer.PA?.gain=="number"&&(this.mixerState.PA.gain=i.mixer.PA.gain),typeof i.mixer.PA?.muted=="boolean"&&(this.mixerState.PA.muted=i.mixer.PA.muted),typeof i.mixer.IEM?.gain=="number"&&(this.mixerState.IEM.gain=i.mixer.IEM.gain),typeof i.mixer.IEM?.muted=="boolean"&&(this.mixerState.IEM.muted=i.mixer.IEM.muted),typeof i.mixer.mic?.gain=="number"&&(this.mixerState.mic.gain=i.mixer.mic.gain),typeof i.mixer.mic?.muted=="boolean"&&(this.mixerState.mic.muted=i.mixer.mic.muted))}}catch(i){console.warn("Failed to load device preferences:",i.message)}}async getAvailableOutputDevices(){try{return(await navigator.mediaDevices.enumerateDevices()).filter(i=>i.kind==="audiooutput")}catch(t){return console.warn("Failed to enumerate output devices:",t.message),[]}}async getAvailableInputDevices(){try{return(await navigator.mediaDevices.enumerateDevices()).filter(i=>i.kind==="audioinput")}catch(t){return console.warn("Failed to enumerate input devices:",t.message),[]}}async setOutputDevice(t,i){try{if(!["PA","IEM"].includes(t))return console.error("Invalid bus type:",t),!1;const e=this.isPlaying;e&&this.pause();const s=this.currentPosition;this.outputDevices[t]=i,this.audioContexts[t]&&await this.audioContexts[t].close();const n={};i!=="default"&&"sinkId"in AudioContext.prototype&&(n.sinkId=i),this.audioContexts[t]=new(window.AudioContext||window.webkitAudioContext)(n),this.outputNodes[t].masterGain=this.audioContexts[t].createGain(),this.outputNodes[t].masterGain.connect(this.audioContexts[t].destination);const a=this.mixerState[t].muted?0:this.dbToLinear(this.mixerState[t].gain);if(this.outputNodes[t].masterGain.gain.value=a,t==="PA"&&(this.outputNodes.PA.analyser=this.audioContexts.PA.createAnalyser(),this.outputNodes.PA.analyser.fftSize=2048,this.outputNodes.PA.analyser.smoothingTimeConstant=.8,this.outputNodes.PA.vocalsPAGain=this.audioContexts.PA.createGain(),this.outputNodes.PA.vocalsPAGain.gain.value=this.vocalsPAEnabled?this.dbToLinear(this.mixerState.IEM.gain):0,this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain)),this.outputNodes[t].sourceNodes.clear(),this.outputNodes[t].gainNodes.clear(),t==="PA"&&this.micEngine&&(this.micEngine.updateAudioContext(this.audioContexts.PA,this.outputNodes.PA.masterGain),await this.micEngine.loadAutoTuneWorklet(),this.micEngine.enableMic)){await this.micEngine.startMicrophoneInput(this.micEngine.inputDevice);const c=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(c)}return this.songData&&await this.reloadAudioBuffersForBus(t),e&&this.songData&&(this.currentPosition=s,await this.play()),!0}catch(e){return console.error(`Failed to set ${t} output device:`,e),!1}}async loadSong(t){return this.songData=t,this.resetPosition(),this.currentPosition=0,this.startTime=0,this.pauseTime=0,this.monitoringStartTime=null,this.stopSongEndMonitoring(),this.mixerState.stems=(t.audio?.sources||[]).map((i,e)=>({id:i.name||i.filename,name:i.name||i.filename,gain:i.gain||0,index:e})),await this.loadAudioBuffers(t),this.micEngine&&this.micEngine.clearPitchReference(),this.reportSongLoaded(),this.reportStateChange(),!0}reportSongLoaded(){if(window.kaiAPI?.renderer&&this.songData){const t=this.getDuration();window.kaiAPI.renderer.songLoaded({path:this.songData.originalFilePath||this.songData.filePath,title:this.songData.metadata?.title||"Unknown",artist:this.songData.metadata?.artist||"Unknown",duration:t,isLoading:!1,format:"kai"}),this.reportMixerState()}}reportMixerState(){window.kaiAPI?.renderer&&this.mixerState&&window.kaiAPI.renderer.updateMixerState(this.mixerState)}async loadAudioBuffers(t){if(!t.audio?.sources){console.warn("No audio sources found in song data");return}(!this.audioContexts.PA||!this.audioContexts.IEM)&&await this.initialize();for(const e of t.audio.sources)try{if(e.audioData&&e.audioData.length>0){const s=e.audioData.buffer.slice(e.audioData.byteOffset,e.audioData.byteOffset+e.audioData.byteLength),n=await this.audioContexts.PA.decodeAudioData(s);this.audioBuffers.set(e.name,n)}else console.warn(`No audio data for source: ${e.name}`)}catch(s){console.error(`Failed to decode audio for ${e.name}:`,s)}let i=0;for(const[e,s]of this.audioBuffers)s.duration>i&&(i=s.duration);i>0?(this.songData.metadata||(this.songData.metadata={}),this.songData.metadata.duration=i):console.warn("No audio buffers loaded, duration remains 0")}async reloadAudioBuffersForBus(t){if(!(!this.songData?.audio?.sources||!this.audioContexts[t])){for(const i of this.songData.audio.sources)if(i.audioData&&i.audioData.length>0)try{if(!this.audioBuffers.has(i.name)){const e=i.audioData.buffer.slice(i.audioData.byteOffset,i.audioData.byteOffset+i.audioData.byteLength),s=await this.audioContexts[t].decodeAudioData(e);this.audioBuffers.set(i.name,s)}}catch(e){console.error(`Failed to reload audio buffer for ${t} - ${i.name}:`,e)}}}async play(){return this.songData?!this.audioContexts.PA||!this.audioContexts.IEM?(console.error("Audio contexts not initialized"),!1):(this.audioContexts.PA.state==="suspended"&&await this.audioContexts.PA.resume(),this.audioContexts.IEM.state==="suspended"&&await this.audioContexts.IEM.resume(),this.isPlaying=!0,this.stopAllSources(),this.createAudioGraph(),this.startAudioSources(),this.startSongEndMonitoring(),this.startStateReporting(),this.micEngine&&this.micEngine.setPlaying(!0),this.reportStateChange(),!0):(console.error("No song loaded"),!1)}pause(){return this.currentPosition=this.getCurrentPosition(),this.isPlaying=!1,this.audioContexts.PA&&(this.pauseTime=this.audioContexts.PA.currentTime),this.stopAllSources(),this.stopSongEndMonitoring(),this.stopStateReporting(),this.micEngine&&this.micEngine.setPlaying(!1),this.reportStateChange(),!0}seek(t){return this.currentPosition=t,this.isPlaying&&(this.stopAllSources(),this.startAudioSources()),this.reportStateChange(),!0}stopAllSources(){this.outputNodes.PA.sourceNodes.size+this.outputNodes.IEM.sourceNodes.size,this.outputNodes.PA.sourceNodes.forEach((t,i)=>{try{t.stop(),t.disconnect()}catch{}}),this.outputNodes.PA.sourceNodes.clear(),this.outputNodes.IEM.sourceNodes.forEach((t,i)=>{try{t.stop(),t.disconnect()}catch{}}),this.outputNodes.IEM.sourceNodes.clear()}createAudioGraph(){!this.audioContexts.PA||!this.audioContexts.IEM||(this.outputNodes.PA.gainNodes.clear(),this.outputNodes.IEM.gainNodes.clear(),this.mixerState.stems.forEach(t=>{const i=this.audioContexts.PA.createGain();i.connect(this.outputNodes.PA.masterGain),this.outputNodes.PA.gainNodes.set(t.name,i);const e=this.audioContexts.IEM.createGain();if(this.isVocalStem(t.name)&&this.mixerState.iemMonoVocals){const s=this.audioContexts.IEM.createChannelMerger(1);e.connect(s),s.connect(this.outputNodes.IEM.masterGain)}else e.connect(this.outputNodes.IEM.masterGain);this.outputNodes.IEM.gainNodes.set(t.name,e),this.updateStemGain(t)}))}startAudioSources(){if(!this.audioContexts.PA||!this.audioContexts.IEM)return;const t=.1,i=this.audioContexts.PA.currentTime+t,e=this.audioContexts.IEM.currentTime+t;this.startTime=i,this.mixerState.stems.forEach(s=>{if(this.isMixdownStem(s.name)){console.log(`⏭️ Skipping mixdown stem: ${s.name}`);return}const n=this.audioBuffers.get(s.name),a=this.outputNodes.PA.gainNodes.get(s.name),c=this.outputNodes.IEM.gainNodes.get(s.name);if(n&&a&&c)try{const r=Math.min(this.currentPosition,n.duration);if(this.isVocalStem(s.name)){const o=this.audioContexts.IEM.createBufferSource();if(o.buffer=n,o.connect(c),o.start(e,r),this.outputNodes.IEM.sourceNodes.set(s.name,o),this.outputNodes.PA.vocalsPAGain){const u=this.audioContexts.PA.createBufferSource();u.buffer=n,u.connect(this.outputNodes.PA.vocalsPAGain),this.micEngine&&this.micEngine.connectMusicSource(u),u.start(i,r),this.outputNodes.PA.sourceNodes.set(s.name+"_vocalsPA",u)}}else{const o=this.audioContexts.PA.createBufferSource();o.buffer=n,o.connect(a),this.outputNodes.PA.analyser&&o.connect(this.outputNodes.PA.analyser),this.isMelodicStem(s.name)&&this.micEngine&&this.micEngine.connectMusicSource(o),o.start(i,r),this.outputNodes.PA.sourceNodes.set(s.name,o),o.onended=()=>{this.isPlaying&&setTimeout(()=>this.checkForSongEnd(),10)}}}catch(r){console.error(`Failed to start source for ${s.name}:`,r)}else console.warn(`No audio buffer or gain nodes for stem: ${s.name}`)})}isVocalStem(t){const i=["vocals","vocal","voice","lead","singing","vox"],e=t.toLowerCase();return i.some(s=>e.includes(s))}isMixdownStem(t){const i=["mixdown","mix","master","full mix","stereo mix"],e=t.toLowerCase();return i.some(s=>e===s||e.includes(`_${s}`)||e.includes(`${s}_`))}isMelodicStem(t){const i=t.toLowerCase();return i.includes("other")||i.includes("music")||i.includes("instrumental")||i.includes("accompaniment")||i.includes("melody")?!0:!(this.isVocalStem(t)||i.includes("drum")||i.includes("percussion")||i.includes("bass"))}updateStemGain(t){const i=this.outputNodes.PA.gainNodes.get(t.name),e=this.outputNodes.IEM.gainNodes.get(t.name),s=this.isVocalStem(t.name);if(!this.audioContexts.PA||!this.audioContexts.IEM)return;const n=Math.pow(10,t.gain/20);s&&e?e.gain.setValueAtTime(n,this.audioContexts.IEM.currentTime):!s&&i&&i.gain.setValueAtTime(n,this.audioContexts.PA.currentTime)}setMasterGain(t,i){if(!["PA","IEM","mic"].includes(t))return!1;if(this.mixerState[t].gain=i,t==="PA"&&this.outputNodes.PA.masterGain){const e=this.dbToLinear(i);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=this.dbToLinear(i);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=this.mixerState.mic.muted?0:this.dbToLinear(i);this.micEngine.setMicrophoneGain(e)}return this.reportMixerState(),!0}toggleMasterMute(t){if(!["PA","IEM","mic"].includes(t))return!1;this.mixerState[t].muted=!this.mixerState[t].muted;const i=this.mixerState[t].muted;if(t==="PA"&&this.outputNodes.PA.masterGain){const e=i?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=i?0:this.dbToLinear(this.mixerState.IEM.gain);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=i?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(e)}return this.reportMixerState(),!0}setMasterMute(t,i){if(!["PA","IEM","mic"].includes(t))return!1;if(this.mixerState[t].muted=i,t==="PA"&&this.outputNodes.PA.masterGain){const e=i?0:this.dbToLinear(this.mixerState.PA.gain);this.outputNodes.PA.masterGain.gain.setValueAtTime(e,this.audioContexts.PA.currentTime)}else if(t==="IEM"&&this.outputNodes.IEM.masterGain){const e=i?0:this.dbToLinear(this.mixerState.IEM.gain);this.outputNodes.IEM.masterGain.gain.setValueAtTime(e,this.audioContexts.IEM.currentTime)}else if(t==="mic"&&this.micEngine){const e=i?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(e)}return!0}setVocalsPAEnabled(t,i=.05){if(!this.outputNodes.PA.vocalsPAGain||!this.audioContexts.PA||this.vocalsPAEnabled===t)return;this.vocalsPAEnabled=t;const e=t?this.dbToLinear(this.mixerState.IEM.gain):0,s=this.audioContexts.PA.currentTime;this.outputNodes.PA.vocalsPAGain.gain.cancelScheduledValues(s),this.outputNodes.PA.vocalsPAGain.gain.linearRampToValueAtTime(e,s+i)}getCurrentPosition(){if(this.isPlaying&&this.audioContexts.PA&&this.startTime>0){const t=this.audioContexts.PA.currentTime-this.startTime,i=this.currentPosition+t,e=this.getDuration();return e>0?Math.min(i,e):i}return this.currentPosition}getCurrentTime(){return this.getCurrentPosition()}getDuration(){return this.songData?.metadata?.duration||0}getPAStream(){return this.outputNodes.PA.streamDestination?.stream??null}getMixerState(){return{PA:this.mixerState.PA,IEM:this.mixerState.IEM,mic:this.mixerState.mic,stems:this.mixerState.stems,isPlaying:this.isPlaying,position:this.getCurrentPosition(),duration:this.getDuration()}}async startMicrophoneInput(t="default"){this.micEngine&&await this.micEngine.startMicrophoneInput(t)}stopMicrophoneInput(){this.micEngine&&this.micEngine.stopMicrophoneInput()}setAutoTuneSettings(t){if(this.micEngine&&(this.micEngine.setAutoTuneSettings(t),this.micEngine.microphoneGain&&Object.hasOwn(t,"enabled"))){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}setMicrophoneGain(t){this.micEngine&&this.micEngine.setMicrophoneGain(t)}setIEMMonoVocals(t){return this.mixerState.iemMonoVocals=t,this.isPlaying?(this.stopAllSources(),this.createAudioGraph(),this.startAudioSources()):this.createAudioGraph(),!0}async loadMicSettings(){try{if(window.kaiAPI.settings&&this.micEngine){const t=await window.kaiAPI.settings.get("micToSpeakers",!1),i=await window.kaiAPI.settings.get("enableMic",!0),e=await window.kaiAPI.settings.get("iemMonoVocals",!0);this.mixerState.micToSpeakers=t,this.mixerState.enableMic=i,this.mixerState.iemMonoVocals=e,this.micEngine.micToSpeakers=t,this.micEngine.enableMic=i;const s=await window.kaiAPI.settings.get("autoTunePreferences",{});if(s.enabled!==void 0&&(this.micEngine.autotuneSettings.enabled=s.enabled),s.strength!==void 0&&(this.micEngine.autotuneSettings.strength=s.strength),s.speed!==void 0&&(this.micEngine.autotuneSettings.speed=s.speed),s.preferVocals!==void 0&&(this.micEngine.autotuneSettings.preferVocals=s.preferVocals),this.micEngine.autotuneSettings.enabled&&this.micEngine.microphoneGain&&this.micEngine.autoTuneWorkletsLoaded){this.micEngine.enableAutoTune();const n=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(n)}}}catch(t){console.error("Failed to load mic/autotune settings:",t)}}setMicToSpeakers(t){if(this.mixerState.micToSpeakers=t,this.micEngine&&(this.micEngine.setMicToSpeakers(t),this.micEngine.microphoneGain)){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}async setEnableMic(t){if(this.mixerState.enableMic=t,this.micEngine&&(await this.micEngine.setEnableMic(t),t&&this.micEngine.microphoneGain)){const i=this.mixerState.mic.muted?0:this.dbToLinear(this.mixerState.mic.gain);this.micEngine.setMicrophoneGain(i)}}stop(){this.isPlaying=!1,this.stopAllSources(),this.stopMicrophoneInput(),this.stopSongEndMonitoring(),this.audioContexts.PA&&(this.audioContexts.PA.close(),this.audioContexts.PA=null),this.audioContexts.IEM&&(this.audioContexts.IEM.close(),this.audioContexts.IEM=null),this.audioBuffers.clear(),this.outputNodes.PA.gainNodes.clear(),this.outputNodes.IEM.gainNodes.clear()}async reinitialize(){this.stop(),await new Promise(t=>setTimeout(t,200)),await this.initialize()}setOnSongEndedCallback(t){this.onSongEndedCallback=t}checkForSongEnd(){const t=this.getDuration(),i=this.getCurrentPosition(),e=this.monitoringStartTime?this.audioContexts.PA.currentTime-this.monitoringStartTime:0;this.isPlaying&&e>2&&t>3&&i>=t-.2&&(this.stopAllSources(),this.stopSongEndMonitoring(),this.pause(),this._triggerSongEnd())}startSongEndMonitoring(){this.songEndMonitor&&clearInterval(this.songEndMonitor),this.monitoringStartTime=this.audioContexts.PA.currentTime,this.songEndMonitor=setInterval(()=>{this.checkForSongEnd()},250)}stopSongEndMonitoring(){this.songEndMonitor&&(clearInterval(this.songEndMonitor),this.songEndMonitor=null)}dbToLinear(t){return Math.pow(10,t/20)}linearToDb(t){return 20*Math.log10(t)}getFormat(){return"kai"}}export{f as KAIPlayer};
2
+ //# sourceMappingURL=kaiPlayer-BsM-WzYQ.js.map