@@ -39,6 +39,7 @@ const Spotify = require('./lib/spotify');
3939// const utils = require('./lib/utils'); // Currently unused
4040const process = require ( 'process' ) ;
4141const parseString = require ( 'xml2js' ) . parseString ;
42+ const OpenAI = require ( 'openai' ) ;
4243const AIHandler = require ( './lib/ai-handler' ) ;
4344const voting = require ( './lib/voting' ) ;
4445const musicHelper = require ( './lib/music-helper' ) ;
@@ -107,6 +108,9 @@ const aiUnparsedFile = path.join(__dirname, 'config/ai-unparsed.log');
107108const WinstonWrapper = require ( './lib/logger' ) ;
108109const Telemetry = require ( './lib/telemetry' ) ;
109110
111+ let openAIClient = null ;
112+ let openAIClientKey = null ;
113+
110114// Helper to load user blacklist
111115function 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 && ! / ^ t t s - 1 (?: - h d ) ? $ / 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 ( / ( a u d i o | t t s | t r a n s c r i b e | w h i s p e r | r e a l t i m e | i m a g e | d a l l | s o r a | e m b e d d i n g | m o d e r a t i o n ) / . test ( modelId ) ) {
3356+ return false ;
3357+ }
3358+ return / ^ ( g p t - | o \d | o \d - | c h a t - l a t e s t | g p t - o s s ) / . test ( modelId ) ;
3359+ }
3360+
3361+ function isLikelyTtsModelId ( id ) {
3362+ const modelId = String ( id || '' ) . toLowerCase ( ) ;
3363+ return / ( ^ t t s - | t t s | a u d i o ) / . test ( modelId ) && ! / ( t r a n s c r i b e | w h i s p e r | r e a l t i m e ) / . 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+
32293427async 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 ) => {
0 commit comments