Skip to content

Commit 18267d2

Browse files
committed
Add OpenAI TTS and model listing help
1 parent 08a2d70 commit 18267d2

13 files changed

Lines changed: 386 additions & 48 deletions

config/config.json.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"discordAdminRoles": ["DJ", "Music Admin", "Admin"],
1717
"openaiApiKey": "sk-proj-...",
1818
"ttsEnabled": true,
19+
"ttsProvider": "google",
20+
"ttsFallbackProvider": "google",
21+
"openaiTtsModel": "gpt-4o-mini-tts",
22+
"openaiTtsVoice": "alloy",
23+
"openaiTtsSpeed": 1,
24+
"openaiTtsInstructions": "",
1925
"webPort" : "8080",
2026
"httpsPort": 8443,
2127
"sslAutoGenerate": true,

index.js

Lines changed: 207 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const Spotify = require('./lib/spotify');
3939
// const utils = require('./lib/utils'); // Currently unused
4040
const process = require('process');
4141
const parseString = require('xml2js').parseString;
42+
const OpenAI = require('openai');
4243
const AIHandler = require('./lib/ai-handler');
4344
const voting = require('./lib/voting');
4445
const musicHelper = require('./lib/music-helper');
@@ -107,6 +108,9 @@ const aiUnparsedFile = path.join(__dirname, 'config/ai-unparsed.log');
107108
const WinstonWrapper = require('./lib/logger');
108109
const Telemetry = require('./lib/telemetry');
109110

111+
let openAIClient = null;
112+
let openAIClientKey = null;
113+
110114
// Helper to load user blacklist
111115
function loadBlacklist() {
112116
try {
@@ -828,6 +832,12 @@ function ensureConfigDefaults() {
828832
aiModel: 'gpt-4o-mini',
829833
aiPrompt: 'You are a funny, upbeat DJ for a Slack music bot controlling Sonos. Reply with a super short, playful one-liner that confirms what you\'ll do, using casual humor and emojis when appropriate.',
830834
aiMoodMirrorEnabled: false,
835+
ttsProvider: 'google',
836+
ttsFallbackProvider: 'google',
837+
openaiTtsModel: 'gpt-4o-mini-tts',
838+
openaiTtsVoice: 'alloy',
839+
openaiTtsSpeed: 1,
840+
openaiTtsInstructions: '',
831841
// Soundcraft mixer integration
832842
soundcraftEnabled: false,
833843
soundcraftIp: '',
@@ -1362,6 +1372,7 @@ const commandRegistry = createCommandRegistry({
13621372
stats: _stats,
13631373
configdump: _configdump,
13641374
aiUnparsed: _aiUnparsed,
1375+
listOpenAIModels: _listOpenAIModels,
13651376
featurerequest: _featurerequest,
13661377
addToSpotifyPlaylist: _addToSpotifyPlaylist,
13671378
diagnostics: _diagnostics,
@@ -2979,6 +2990,11 @@ async function _setconfig(input, channel, userName) {
29792990
> \`aiModel\`: ${config.get('aiModel') || 'gpt-4o-mini'}
29802991
> \`aiPrompt\`: ${(config.get('aiPrompt') || '').slice(0, 80)}${(config.get('aiPrompt') || '').length > 80 ? '…' : ''}
29812992
> \`aiMoodMirrorEnabled\`: ${config.get('aiMoodMirrorEnabled') === true}
2993+
> \`ttsProvider\`: ${config.get('ttsProvider') || 'google'}
2994+
> \`ttsFallbackProvider\`: ${config.get('ttsFallbackProvider') || 'google'}
2995+
> \`openaiTtsModel\`: ${config.get('openaiTtsModel') || 'gpt-4o-mini-tts'}
2996+
> \`openaiTtsVoice\`: ${config.get('openaiTtsVoice') || 'alloy'}
2997+
> \`openaiTtsSpeed\`: ${Number(config.get('openaiTtsSpeed') || 1)}
29822998
> \`defaultTheme\`: ${config.get('defaultTheme') || '(not set)'}
29832999
> \`themePercentage\`: ${config.get('themePercentage') || 0}%
29843000
> \`telemetryEnabled\`: ${config.get('telemetryEnabled')}
@@ -2994,6 +3010,8 @@ async function _setconfig(input, channel, userName) {
29943010
*Example:* \`setconfig defaultTheme lounge\`
29953011
*Example:* \`setconfig themePercentage 30\`
29963012
*Example:* \`setconfig aiMoodMirrorEnabled true\`
3013+
*Example:* \`setconfig ttsProvider openai\`
3014+
*Example:* \`setconfig openaiTtsVoice nova\`
29973015
*Example:* \`setconfig telemetryEnabled false\`
29983016
*Example:* \`setconfig soundcraftEnabled true\`
29993017
*Example:* \`setconfig soundcraftIp 192.168.1.100\`
@@ -3019,9 +3037,15 @@ async function _setconfig(input, channel, userName) {
30193037
voteTimeLimitMinutes: { type: 'number', min: 1, max: 60 },
30203038
themePercentage: { type: 'number', min: 0, max: 100 },
30213039
crossfadeDurationSeconds: { type: 'number', min: 0, max: 30 },
3022-
aiModel: { type: 'string', minLen: 1, maxLen: 50, allowed: ['gpt-4o-mini', 'gpt-4o'] },
3040+
aiModel: { type: 'string', minLen: 1, maxLen: 100 },
30233041
aiPrompt: { type: 'string', minLen: 1, maxLen: 500 },
30243042
aiMoodMirrorEnabled: { type: 'boolean' },
3043+
ttsProvider: { type: 'string', minLen: 4, maxLen: 6, allowed: ['google', 'openai'] },
3044+
ttsFallbackProvider: { type: 'string', minLen: 4, maxLen: 6, allowed: ['google', 'none'] },
3045+
openaiTtsModel: { type: 'string', minLen: 1, maxLen: 100 },
3046+
openaiTtsVoice: { type: 'string', minLen: 2, maxLen: 50 },
3047+
openaiTtsInstructions: { type: 'string', minLen: 0, maxLen: 500 },
3048+
openaiTtsSpeed: { type: 'number', min: 0.25, max: 4 },
30253049
defaultTheme: { type: 'string', minLen: 0, maxLen: 100 },
30263050
telemetryEnabled: { type: 'boolean' },
30273051
soundcraftEnabled: { type: 'boolean' },
@@ -3105,7 +3129,9 @@ async function _setconfig(input, channel, userName) {
31053129
});
31063130
} else if (configDef.type === 'string') {
31073131
const newValue = input.slice(2).join(' ').trim();
3108-
if (newValue.length < (configDef.minLen || 1) || newValue.length > (configDef.maxLen || 500)) {
3132+
const minLen = configDef.minLen ?? 1;
3133+
const maxLen = configDef.maxLen ?? 500;
3134+
if (newValue.length < minLen || newValue.length > maxLen) {
31093135
_slackMessage(`📝 Value length for \`${actualKey}\` must be between ${configDef.minLen} and ${configDef.maxLen} characters.`, channel);
31103136
return;
31113137
}
@@ -3226,6 +3252,178 @@ function _addToSpotifyPlaylist(input, channel) {
32263252
_slackMessage('🚧 This feature is still under construction! Check back later! 🛠️', channel);
32273253
}
32283254

3255+
function getOpenAIClient() {
3256+
const apiKey = config.get('openaiApiKey');
3257+
if (!apiKey) {
3258+
throw new Error('openaiApiKey is not configured');
3259+
}
3260+
if (typeof apiKey !== 'string' || !apiKey.startsWith('sk-')) {
3261+
throw new Error('openaiApiKey has invalid format');
3262+
}
3263+
if (!openAIClient || openAIClientKey !== apiKey) {
3264+
openAIClient = new OpenAI({ apiKey });
3265+
openAIClientKey = apiKey;
3266+
}
3267+
return openAIClient;
3268+
}
3269+
3270+
function getTtsProvider() {
3271+
const provider = String(config.get('ttsProvider') || 'google').trim().toLowerCase();
3272+
return provider === 'openai' ? 'openai' : 'google';
3273+
}
3274+
3275+
function getTtsFallbackProvider() {
3276+
const fallback = String(config.get('ttsFallbackProvider') || 'google').trim().toLowerCase();
3277+
return fallback === 'none' ? 'none' : 'google';
3278+
}
3279+
3280+
function getOpenAITtsSpeed() {
3281+
const speed = Number(config.get('openaiTtsSpeed') || 1);
3282+
if (!Number.isFinite(speed)) {
3283+
return 1;
3284+
}
3285+
return Math.min(4, Math.max(0.25, speed));
3286+
}
3287+
3288+
async function generateGoogleTtsBuffer(fullTtsText) {
3289+
const audioResults = await googleTTS.getAllAudioBase64(fullTtsText, {
3290+
lang: 'en',
3291+
slow: false,
3292+
host: 'https://translate.google.com',
3293+
timeout: 10000,
3294+
splitPunct: ',.?!;:',
3295+
});
3296+
3297+
const audioBuffers = audioResults.map(result => Buffer.from(result.base64, 'base64'));
3298+
return Buffer.concat(audioBuffers);
3299+
}
3300+
3301+
async function generateOpenAITtsBuffer(fullTtsText) {
3302+
const model = String(config.get('openaiTtsModel') || 'gpt-4o-mini-tts').trim();
3303+
const voice = String(config.get('openaiTtsVoice') || 'alloy').trim();
3304+
const instructions = String(config.get('openaiTtsInstructions') || '').trim();
3305+
const request = {
3306+
model,
3307+
voice,
3308+
input: fullTtsText,
3309+
response_format: 'mp3',
3310+
speed: getOpenAITtsSpeed()
3311+
};
3312+
3313+
if (instructions && !/^tts-1(?:-hd)?$/i.test(model)) {
3314+
request.instructions = instructions;
3315+
}
3316+
3317+
const speech = await getOpenAIClient().audio.speech.create(request);
3318+
const arrayBuffer = await speech.arrayBuffer();
3319+
return Buffer.from(arrayBuffer);
3320+
}
3321+
3322+
async function generateTtsBuffer(fullTtsText) {
3323+
const provider = getTtsProvider();
3324+
if (provider === 'google') {
3325+
return {
3326+
buffer: await generateGoogleTtsBuffer(fullTtsText),
3327+
provider: 'google'
3328+
};
3329+
}
3330+
3331+
try {
3332+
return {
3333+
buffer: await generateOpenAITtsBuffer(fullTtsText),
3334+
provider: 'openai'
3335+
};
3336+
} catch (err) {
3337+
const fallbackProvider = getTtsFallbackProvider();
3338+
if (fallbackProvider !== 'google') {
3339+
throw err;
3340+
}
3341+
3342+
logger.warn('OpenAI TTS failed, falling back to Google TTS: ' + (err.message || err));
3343+
return {
3344+
buffer: await generateGoogleTtsBuffer(fullTtsText),
3345+
provider: 'google',
3346+
fallbackFrom: 'openai',
3347+
fallbackError: err.message || String(err)
3348+
};
3349+
}
3350+
}
3351+
3352+
function isLikelyChatModelId(id) {
3353+
const modelId = String(id || '').toLowerCase();
3354+
if (!modelId) return false;
3355+
if (/(audio|tts|transcribe|whisper|realtime|image|dall|sora|embedding|moderation)/.test(modelId)) {
3356+
return false;
3357+
}
3358+
return /^(gpt-|o\d|o\d-|chat-latest|gpt-oss)/.test(modelId);
3359+
}
3360+
3361+
function isLikelyTtsModelId(id) {
3362+
const modelId = String(id || '').toLowerCase();
3363+
return /(^tts-|tts|audio)/.test(modelId) && !/(transcribe|whisper|realtime)/.test(modelId);
3364+
}
3365+
3366+
function filterOpenAIModelIds(modelIds, filter) {
3367+
if (filter === 'all') {
3368+
return modelIds;
3369+
}
3370+
if (filter === 'tts' || filter === 'speech') {
3371+
return modelIds.filter(isLikelyTtsModelId);
3372+
}
3373+
return modelIds.filter(isLikelyChatModelId);
3374+
}
3375+
3376+
async function listOpenAIModelIds() {
3377+
const page = await getOpenAIClient().models.list();
3378+
const models = Array.isArray(page && page.data) ? page.data : [];
3379+
return models
3380+
.map(model => model && model.id)
3381+
.filter(Boolean)
3382+
.sort((a, b) => a.localeCompare(b));
3383+
}
3384+
3385+
function formatModelListForSlack(modelIds, filter) {
3386+
const limit = 40;
3387+
const shown = modelIds.slice(0, limit);
3388+
const title = filter === 'all'
3389+
? 'All accessible OpenAI models'
3390+
: filter === 'tts' || filter === 'speech'
3391+
? 'Accessible OpenAI TTS/audio model IDs'
3392+
: 'Accessible OpenAI model IDs likely usable for chat';
3393+
const lines = shown.map(id => `> \`${id}\``).join('\n') || '> _(none found)_';
3394+
const suffix = modelIds.length > shown.length
3395+
? `\n_Showing ${shown.length} of ${modelIds.length}. Use \`aimodels all\` to inspect the full list._`
3396+
: '';
3397+
3398+
return `*🤖 ${title}*\n${lines}${suffix}`;
3399+
}
3400+
3401+
async function _listOpenAIModels(input, channel, userName) {
3402+
await _logUserAction(userName, 'aimodels');
3403+
const filter = String(input[1] || 'chat').trim().toLowerCase();
3404+
const normalizedFilter = ['all', 'chat', 'tts', 'speech'].includes(filter) ? filter : 'chat';
3405+
3406+
try {
3407+
const allModelIds = await listOpenAIModelIds();
3408+
const modelIds = filterOpenAIModelIds(allModelIds, normalizedFilter);
3409+
const message =
3410+
`${formatModelListForSlack(modelIds, normalizedFilter)}\n\n` +
3411+
`Current \`aiModel\`: \`${config.get('aiModel') || 'gpt-4o-mini'}\`\n` +
3412+
`Current \`openaiTtsModel\`: \`${config.get('openaiTtsModel') || 'gpt-4o-mini-tts'}\`\n` +
3413+
`_Try: \`aimodels tts\`, \`aimodels all\`, or \`setconfig aiModel <model-id>\`._`;
3414+
_slackMessage(message, channel);
3415+
} catch (err) {
3416+
logger.error('Failed to list OpenAI models: ' + (err.message || err));
3417+
if (err.status === 401) {
3418+
_slackMessage('❌ OpenAI API key is invalid or unauthorized. Check `openaiApiKey`.', channel);
3419+
} else if (err.status === 403) {
3420+
_slackMessage('❌ OpenAI key cannot list models. For restricted keys, enable `List models` → `Read`.', channel);
3421+
} else {
3422+
_slackMessage(`❌ Could not list OpenAI models: ${err.message || err}`, channel);
3423+
}
3424+
}
3425+
}
3426+
32293427
async function _tts(input, channel) {
32303428
// Admin check now handled in processInput (platform-aware)
32313429
const text = input.slice(1).join(' ');
@@ -3242,22 +3440,14 @@ async function _tts(input, channel) {
32423440
const fullTtsText = `${introMessage}... ... ${text}`;
32433441

32443442
try {
3245-
// Get audio as base64 using the new library (handles long text automatically)
3246-
const audioResults = await googleTTS.getAllAudioBase64(fullTtsText, {
3247-
lang: 'en',
3248-
slow: false,
3249-
host: 'https://translate.google.com',
3250-
timeout: 10000,
3251-
splitPunct: ',.?!;:',
3252-
});
3253-
3254-
// Combine all audio chunks into a single buffer
3255-
const audioBuffers = audioResults.map(result => Buffer.from(result.base64, 'base64'));
3256-
const combinedBuffer = Buffer.concat(audioBuffers);
3443+
const ttsResult = await generateTtsBuffer(fullTtsText);
32573444

3258-
// Write the combined audio to file (async)
3259-
await fs.promises.writeFile(ttsFilePath, combinedBuffer);
3260-
logger.info('TTS audio saved to: ' + ttsFilePath);
3445+
// Write the generated audio to file (async)
3446+
await fs.promises.writeFile(ttsFilePath, ttsResult.buffer);
3447+
logger.info(`TTS audio saved to: ${ttsFilePath} (provider=${ttsResult.provider})`);
3448+
if (ttsResult.fallbackFrom) {
3449+
_slackMessage(`⚠️ OpenAI TTS failed, using Google TTS fallback. Error: \`${ttsResult.fallbackError}\``, channel);
3450+
}
32613451

32623452
// Get TTS file duration
32633453
const fileDuration = await new Promise((resolve, reject) => {

lib/admin-api.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const SPOTIFY_STATUS_CACHE_TTL = 5 * 60 * 1000;
1010
const ALLOWED_CONFIG_KEYS = [
1111
'adminChannel', 'standardChannel', 'maxVolume', 'market',
1212
'gongLimit', 'voteLimit', 'voteImmuneLimit', 'flushVoteLimit',
13-
'voteTimeLimitMinutes', 'ttsEnabled', 'logLevel', 'ipAddress',
13+
'voteTimeLimitMinutes', 'ttsEnabled', 'ttsProvider', 'ttsFallbackProvider',
14+
'openaiTtsModel', 'openaiTtsVoice', 'openaiTtsSpeed', 'openaiTtsInstructions', 'logLevel', 'ipAddress',
1415
'webPort', 'httpsPort', 'sonos', 'defaultTheme', 'themePercentage',
1516
'openaiApiKey', 'aiModel', 'aiMoodMirrorEnabled', 'soundcraftEnabled', 'soundcraftIp', 'soundcraftChannels', 'crossfadeEnabled',
1617
'webauthnEnabled', 'webauthnRpName', 'webauthnRpId', 'webauthnOrigin', 'webauthnRequireUserVerification', 'webauthnPreferPlatformOnly',
@@ -27,6 +28,7 @@ const NUMERIC_CONFIG_KEYS = new Set([
2728
'webPort',
2829
'httpsPort',
2930
'themePercentage',
31+
'openaiTtsSpeed',
3032
'webauthnTimeout',
3133
'webauthnChallengeExpiration',
3234
'webauthnMaxCredentials'
@@ -431,6 +433,12 @@ function createAdminApi(deps) {
431433
flushVoteLimit: config.get('flushVoteLimit') || 6,
432434
voteTimeLimitMinutes: config.get('voteTimeLimitMinutes') || 2,
433435
ttsEnabled: config.get('ttsEnabled') !== false,
436+
ttsProvider: config.get('ttsProvider') || 'google',
437+
ttsFallbackProvider: config.get('ttsFallbackProvider') || 'google',
438+
openaiTtsModel: config.get('openaiTtsModel') || 'gpt-4o-mini-tts',
439+
openaiTtsVoice: config.get('openaiTtsVoice') || 'alloy',
440+
openaiTtsSpeed: Number(config.get('openaiTtsSpeed') || 1),
441+
openaiTtsInstructions: config.get('openaiTtsInstructions') || '',
434442
logLevel: config.get('logLevel') || 'info',
435443
ipAddress: config.get('ipAddress') || '',
436444
webPort: config.get('webPort') || 8080,

lib/command-registry.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function createCommandRegistry(deps) {
2727
stats,
2828
configdump,
2929
aiUnparsed,
30+
listOpenAIModels,
3031
featurerequest,
3132
addToSpotifyPlaylist,
3233
diagnostics,
@@ -83,6 +84,7 @@ function createCommandRegistry(deps) {
8384
['stats', { fn: stats, admin: true }],
8485
['configdump', { fn: configdump, admin: true, aliases: ['cfgdump', 'confdump'] }],
8586
['aiunparsed', { fn: aiUnparsed, admin: true, aliases: ['aiun', 'aiunknown'] }],
87+
['aimodels', { fn: (args, ch, u) => listOpenAIModels(args, ch, u), admin: true, aliases: ['openaimodels', 'models'] }],
8688
['featurerequest', { fn: featurerequest, admin: false, aliases: ['feuturerequest'] }],
8789
['fr', { fn: featurerequest, admin: false }],
8890
['test', { fn: (args, ch) => addToSpotifyPlaylist(args, ch), admin: true }],

lib/setup-handler.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ async function isSetupNeeded() {
9797
market: 'US',
9898
logLevel: 'info',
9999
ttsEnabled: true,
100+
ttsProvider: 'google',
101+
ttsFallbackProvider: 'google',
102+
openaiTtsModel: 'gpt-4o-mini-tts',
103+
openaiTtsVoice: 'alloy',
104+
openaiTtsSpeed: 1,
105+
openaiTtsInstructions: '',
100106
webPort: 8080
101107
};
102108
await fs.writeFile(CONFIG_PATH, JSON.stringify(minimalConfig, null, 2) + '\n', 'utf8');
@@ -239,6 +245,12 @@ async function saveConfig(configData) {
239245
market: 'US',
240246
logLevel: 'info',
241247
ttsEnabled: true,
248+
ttsProvider: 'google',
249+
ttsFallbackProvider: 'google',
250+
openaiTtsModel: 'gpt-4o-mini-tts',
251+
openaiTtsVoice: 'alloy',
252+
openaiTtsSpeed: 1,
253+
openaiTtsInstructions: '',
242254
webPort: 8080
243255
};
244256
}

public/setup/admin.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<title>SlackONOS Admin</title>
77
<!-- Preload CDN scripts for faster loading -->
88
<link rel="preload" as="script" href="https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@9.0.0/dist/bundle/index.umd.min.js">
9-
<link rel="stylesheet" href="/setup/setup.css?v=6.1">
9+
<link rel="stylesheet" href="/setup/setup.css?v=6.2">
1010
</head>
1111
<body class="admin-page">
1212
<div class="admin-container">

0 commit comments

Comments
 (0)