diff --git a/server.log b/server.log index 5f096a3..a9b1a45 100644 --- a/server.log +++ b/server.log @@ -60,3 +60,19 @@ INFO:mcp.server.lowlevel.server:Warning: PydanticDeprecatedSince20: The `parse_o INFO: 127.0.0.1:59500 - "DELETE /mcp HTTP/1.1" 307 Temporary Redirect INFO:mcp.server.streamable_http:Terminating session: b505497d41044d28bb59b2684753a6fc INFO: 127.0.0.1:59500 - "DELETE /mcp/ HTTP/1.1" 200 OK +INFO: 127.0.0.1:60605 - "POST /mcp HTTP/1.1" 307 Temporary Redirect +INFO:mcp.server.streamable_http_manager:Created new transport with session ID: 1bb3384b1c43444e8c5092b7426d62a4 +INFO: 127.0.0.1:60605 - "POST /mcp/ HTTP/1.1" 200 OK +INFO: 127.0.0.1:60608 - "GET /mcp HTTP/1.1" 307 Temporary Redirect +INFO: 127.0.0.1:60609 - "POST /mcp HTTP/1.1" 307 Temporary Redirect +INFO: 127.0.0.1:60608 - "GET /mcp/ HTTP/1.1" 200 OK +INFO: 127.0.0.1:60609 - "POST /mcp/ HTTP/1.1" 202 Accepted +INFO: 127.0.0.1:60611 - "POST /mcp HTTP/1.1" 307 Temporary Redirect +INFO: 127.0.0.1:60611 - "POST /mcp/ HTTP/1.1" 200 OK +INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest +INFO:httpx:HTTP Request: POST https://api.cloud.llamaindex.ai/api/v1/pipelines/884e242c-86dd-4824-8347-e6dfb91d98dc/retrieve "HTTP/1.1 200 OK" +INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 200 OK" +INFO:mcp.server.lowlevel.server:Warning: PydanticDeprecatedSince20: The `parse_obj` method is deprecated; use `model_validate` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.11/migration/ +INFO: 127.0.0.1:60625 - "DELETE /mcp HTTP/1.1" 307 Temporary Redirect +INFO:mcp.server.streamable_http:Terminating session: 1bb3384b1c43444e8c5092b7426d62a4 +INFO: 127.0.0.1:60625 - "DELETE /mcp/ HTTP/1.1" 200 OK diff --git a/src/notebookllama/audio.py b/src/notebookllama/audio.py index 0e8aaf9..688987a 100644 --- a/src/notebookllama/audio.py +++ b/src/notebookllama/audio.py @@ -83,19 +83,23 @@ class PodcastGenerator(BaseModel): ) return MultiTurnConversation.model_validate_json(response.message.content) - async def _conversation_audio(self, conversation: MultiTurnConversation) -> str: + async def _conversation_audio(self, conversation: MultiTurnConversation, voice1_id: str = None, voice2_id: str = None) -> str: + # Default voices if not specified + voice1_id = voice1_id or "nPczCjzI2devNBz1zQrb" + voice2_id = voice2_id or "Xb7hH8MSUJpSbSDYk0k2" + files: List[str] = [] for turn in conversation.conversation: if turn.speaker == "speaker1": speech_iterator = self.client.text_to_speech.convert( - voice_id="nPczCjzI2devNBz1zQrb", + voice_id=voice1_id, text=turn.content, output_format="mp3_22050_32", model_id="eleven_turbo_v2_5", ) else: speech_iterator = self.client.text_to_speech.convert( - voice_id="Xb7hH8MSUJpSbSDYk0k2", + voice_id=voice2_id, text=turn.content, output_format="mp3_22050_32", model_id="eleven_turbo_v2_5", @@ -127,9 +131,9 @@ class PodcastGenerator(BaseModel): return output_path - async def create_conversation(self, file_transcript: str): + async def create_conversation(self, file_transcript: str, voice1_id: str = None, voice2_id: str = None): conversation = await self._conversation_script(file_transcript=file_transcript) - podcast_file = await self._conversation_audio(conversation=conversation) + podcast_file = await self._conversation_audio(conversation=conversation, voice1_id=voice1_id, voice2_id=voice2_id) return podcast_file diff --git a/src/notebookllama/background_tasks.py b/src/notebookllama/background_tasks.py index dad9504..aecc5a5 100644 --- a/src/notebookllama/background_tasks.py +++ b/src/notebookllama/background_tasks.py @@ -217,6 +217,8 @@ async def execute_podcast_task(task_id: int): target_length = params.get('target_length', 10) custom_theme = params.get('custom_theme') custom_prompt = params.get('custom_prompt') + voice1_id = params.get('voice1_id') + voice2_id = params.get('voice2_id') try: documents = get_notebook_documents(notebook_id) @@ -246,8 +248,12 @@ async def execute_podcast_task(task_id: int): # Generate script script = await generate_podcast_script_from_outline(outline, combined_content) - # Generate audio - audio_file = await PODCAST_GEN.create_conversation(file_transcript=script) + # Generate audio with selected voices + audio_file = await PODCAST_GEN.create_conversation( + file_transcript=script, + voice1_id=voice1_id, + voice2_id=voice2_id + ) # Save to notebook save_notebook_podcast(notebook_id, audio_file) diff --git a/src/notebookllama/database.py b/src/notebookllama/database.py index 4a96da7..0c2d627 100644 --- a/src/notebookllama/database.py +++ b/src/notebookllama/database.py @@ -20,6 +20,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) class PermissionLevel(enum.Enum): READ = "read" WRITE = "write" + SHARE = "share" # Write + Share permissions ADMIN = "admin" diff --git a/src/notebookllama/pages/2_Notebook_Detail.py b/src/notebookllama/pages/2_Notebook_Detail.py index 844ea04..899fbf2 100644 --- a/src/notebookllama/pages/2_Notebook_Detail.py +++ b/src/notebookllama/pages/2_Notebook_Detail.py @@ -78,9 +78,10 @@ if not is_owner and not is_shared: finally: db.close() -# Determine read-only mode +# Determine permissions is_read_only = is_shared and permission == PermissionLevel.READ -can_edit = is_owner or (is_shared and permission in [PermissionLevel.WRITE, PermissionLevel.ADMIN]) +can_edit = is_owner or (is_shared and permission in [PermissionLevel.WRITE, PermissionLevel.SHARE, PermissionLevel.ADMIN]) +can_share = is_owner or (is_shared and permission in [PermissionLevel.SHARE, PermissionLevel.ADMIN]) # Header col_header1, col_header2 = st.columns([3, 1]) @@ -120,7 +121,7 @@ if can_edit: st.rerun() with col3: - if is_owner and st.button("📤 Share Notebook", use_container_width=True): + if can_share and st.button("📤 Share Notebook", use_container_width=True): st.session_state.sharing_notebook_id = notebook.id st.rerun() @@ -184,8 +185,8 @@ if st.session_state.get("adding_docs_to_notebook") == notebook.id: st.markdown("---") -# Share notebook section (owner only) -if is_owner and st.session_state.get("sharing_notebook_id") == notebook.id: +# Share notebook section (owner or users with SHARE permission) +if can_share and st.session_state.get("sharing_notebook_id") == notebook.id: st.subheader("📤 Share Notebook") # Show existing shares first @@ -216,10 +217,20 @@ if is_owner and st.session_state.get("sharing_notebook_id") == notebook.id: # Add new share st.markdown("**Add new collaborator:**") share_email = st.text_input("Enter email to share with:") + + # Only owners can grant SHARE permission + if is_owner: + permission_options = [PermissionLevel.READ, PermissionLevel.WRITE, PermissionLevel.SHARE] + help_text = "Read: View only | Write: View + Edit + Add Docs | Share: Write + Can share with others" + else: + permission_options = [PermissionLevel.READ, PermissionLevel.WRITE] + help_text = "Read: View only | Write: View + Edit + Add Docs" + permission = st.selectbox( "Permission Level:", - options=[PermissionLevel.READ, PermissionLevel.WRITE], - format_func=lambda x: x.value.capitalize() + options=permission_options, + format_func=lambda x: "Write + Share" if x == PermissionLevel.SHARE else x.value.capitalize(), + help=help_text ) col_share1, col_share2 = st.columns(2) @@ -308,6 +319,41 @@ if st.session_state.get("generating_podcast") == notebook.id: help="Specific instructions for how to structure the conversation" ) + # Voice selection + st.markdown("---") + st.markdown("**🎙️ Voice Selection:**") + + # Popular ElevenLabs voices + VOICE_OPTIONS = { + "Brian (Default Male)": "nPczCjzI2devNBz1zQrb", + "Sarah (Default Female)": "Xb7hH8MSUJpSbSDYk0k2", + "Adam (Deep Male)": "pNInz6obpgDQGcFmaJgB", + "Bella (Friendly Female)": "EXAVITQu4vr4xnSDxMaL", + "Charlie (Casual Male)": "IKne3meq5aSn9XLyUdCD", + "Charlotte (Professional Female)": "XB0fDUnXU5powFXDhCwa", + "Daniel (British Male)": "onwK4e9ZLuTAKqWW03F9", + "Emily (Warm Female)": "LcfcDJNUP1GQjkzn1xUU" + } + + col_v1, col_v2 = st.columns(2) + with col_v1: + voice1_name = st.selectbox( + "Speaker 1 (Analytical):", + options=list(VOICE_OPTIONS.keys()), + index=0, + help="Usually asks questions and probes deeper" + ) + voice1_id = VOICE_OPTIONS[voice1_name] + + with col_v2: + voice2_name = st.selectbox( + "Speaker 2 (Explanatory):", + options=list(VOICE_OPTIONS.keys()), + index=1, + help="Usually explains concepts and provides context" + ) + voice2_id = VOICE_OPTIONS[voice2_name] + col_p1, col_p2 = st.columns(2) with col_p1: if st.button("Generate in Background", type="primary", key="generate_podcast_btn"): @@ -316,7 +362,9 @@ if st.session_state.get("generating_podcast") == notebook.id: params = { 'target_length': target_length, 'custom_theme': custom_theme if custom_theme else None, - 'custom_prompt': custom_prompt if custom_prompt else None + 'custom_prompt': custom_prompt if custom_prompt else None, + 'voice1_id': voice1_id, + 'voice2_id': voice2_id } # Start background task