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.
- package/README.md +9 -3
- package/package.json +4 -2
- 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
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
|
-
- **
|
|
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
|
-
- **
|
|
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**:
|
|
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.
|
|
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
|
+
}
|
package/src/main/preload.js
CHANGED
|
@@ -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);
|
package/src/main/webServer.js
CHANGED
|
@@ -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-
|
|
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
|