rairo commited on
Commit
bf87b45
·
verified ·
1 Parent(s): 1ce142f

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +214 -71
main.py CHANGED
@@ -27,6 +27,11 @@ from youtube_transcript_api import YouTubeTranscriptApi
27
  import arxiv # For ArXiv
28
  from elevenlabs import play, stream, save
29
  import math
 
 
 
 
 
30
 
31
  # --- Environment Variables ---
32
  # Load environment variables if using a .env file (optional, good practice)
@@ -891,18 +896,47 @@ def submit_quiz_attempt(quiz_id):
891
 
892
 
893
  # Modified speak_notes endpoint with ElevenLabs Studio API and chunking
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
  @app.route('/api/tutor/notes/<uuid:notes_id>/speak', methods=['POST'])
895
  def speak_notes(notes_id):
896
- """Generate TTS audio for notes and store in Supabase Storage"""
 
 
 
 
 
 
 
 
 
897
  user, error = verify_token(request.headers.get('Authorization'))
898
  if error:
899
  return jsonify({'error': error['error']}), error['status']
900
 
901
  if not supabase or not elevenlabs_client:
 
902
  return jsonify({'error': 'Backend service unavailable'}), 503
903
 
904
  try:
905
  # 1. Verify note ownership and get content
 
906
  note_res = supabase.table('notes') \
907
  .select('user_id, content, tts_audio_url') \
908
  .eq('id', str(notes_id)) \
@@ -911,6 +945,7 @@ def speak_notes(notes_id):
911
  .execute()
912
 
913
  if not note_res.data:
 
914
  return jsonify({'error': 'Note not found or unauthorized'}), 404
915
 
916
  # 2. Check user status and credits
@@ -920,98 +955,206 @@ def speak_notes(notes_id):
920
  .single() \
921
  .execute()
922
 
923
- if profile_res.data['suspended']:
 
 
 
 
 
 
924
  return jsonify({'error': 'Account suspended'}), 403
925
- if profile_res.data['credits'] < 5:
926
- return jsonify({'error': 'Insufficient credits (Need 5)'}), 402
927
 
928
- # 3. Return existing audio if available
929
- if note_res.data.get('tts_audio_url'):
 
 
 
 
 
 
 
 
930
  return jsonify({
931
  'success': True,
932
- 'audio_url': note_res.data['tts_audio_url'],
933
- 'message': 'Using existing audio file'
 
934
  })
935
 
936
- notes_content = note_res.data['content']
937
- if not notes_content:
 
938
  return jsonify({'error': 'Notes content is empty'}), 400
939
 
940
- # 4. Generate TTS Audio with chunking
941
- CHUNK_SIZE = 2000
942
- chunks = [notes_content[i:i+CHUNK_SIZE] for i in range(0, len(notes_content), CHUNK_SIZE)]
943
-
944
- audio_bytes = b""
945
- for chunk in chunks:
 
 
 
946
  try:
947
- chunk_audio = elevenlabs_client.generate(
948
- text=chunk,
949
- voice="Rachel",
950
- model="eleven_multilingual_v2",
951
- stream=True
 
952
  )
953
- audio_bytes += b"".join(chunk_audio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  except Exception as e:
955
- logging.error(f"Error generating chunk: {str(e)}")
956
- raise RuntimeError(f"Audio generation failed: {str(e)}")
 
957
 
958
- if not audio_bytes:
959
- raise RuntimeError("Generated empty audio file")
 
 
 
 
 
 
 
 
 
 
 
 
 
960
 
961
  # 5. Save to Supabase Storage
962
- bucket_name = 'notes-audio'
 
963
  file_path = f'{user.id}/{str(notes_id)}.mp3'
964
-
 
965
  try:
966
- # Upload audio file - pass bytes directly
967
- upload_res = supabase.storage.from_(bucket_name).upload(
 
968
  path=file_path,
969
- file=audio_bytes,
970
- file_options={"content-type": "audio/mpeg"}
971
  )
972
-
973
- # Get public URL
974
- audio_url = supabase.storage.from_(bucket_name).get_public_url(file_path)
975
-
976
- # 6. Update database records
977
- update_res = supabase.table('notes') \
978
- .update({'tts_audio_url': audio_url}) \
979
- .eq('id', str(notes_id)) \
980
- .eq('user_id', user.id) \
981
- .execute()
982
-
983
- if update_res.error:
984
- raise ConnectionError(update_res.error.message)
985
-
986
- # 7. Deduct credits
987
- new_credits = profile_res.data['credits'] - 5
988
- credit_res = supabase.table('profiles') \
989
- .update({'credits': new_credits}) \
990
- .eq('id', user.id) \
991
- .execute()
992
-
993
- if credit_res.error:
994
- raise ConnectionError(credit_res.error.message)
995
 
996
- return jsonify({
997
- 'success': True,
998
- 'audio_url': audio_url,
999
- 'remaining_credits': new_credits
1000
- })
1001
 
1002
- except Exception as upload_error:
1003
- # Clean up failed upload
1004
- try:
1005
- supabase.storage.from_(bucket_name).remove([file_path])
1006
- except Exception as cleanup_error:
1007
- logging.error(f"Cleanup failed: {cleanup_error}")
1008
-
1009
- logging.error(f"Upload failed: {str(upload_error)}")
1010
- raise upload_error
1011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  except Exception as e:
1013
- logging.error(f"Speak endpoint error: {traceback.format_exc()}")
1014
- return jsonify({'error': str(e)}), 500
 
 
 
1015
 
1016
  # New endpoint to view existing audio URL
1017
  @app.route('/api/tutor/notes/<uuid:notes_id>/audio', methods=['GET'])
 
27
  import arxiv # For ArXiv
28
  from elevenlabs import play, stream, save
29
  import math
30
+ import pydub
31
+ import logging
32
+ import traceback
33
+ import uuid
34
+ from io import BytesIO # To handle in-memory bytes
35
 
36
  # --- Environment Variables ---
37
  # Load environment variables if using a .env file (optional, good practice)
 
896
 
897
 
898
  # Modified speak_notes endpoint with ElevenLabs Studio API and chunking
899
+
900
+ try:
901
+ from pydub import AudioSegment
902
+ PYDUB_AVAILABLE = True
903
+ except ImportError:
904
+ PYDUB_AVAILABLE = False
905
+ logging.warning("pydub library not found or ffmpeg might be missing. Audio chunk concatenation will fail. Please install pydub and ensure ffmpeg is in your system's PATH.")
906
+ # Define a dummy AudioSegment class if pydub is not installed to avoid NameError later
907
+ class AudioSegment:
908
+ @staticmethod
909
+ def from_file(*args, **kwargs):
910
+ raise ImportError("pydub/ffmpeg not installed or accessible")
911
+ def __add__(self, other):
912
+ raise ImportError("pydub/ffmpeg not installed or accessible")
913
+ def export(self, *args, **kwargs):
914
+ raise ImportError("pydub/ffmpeg not installed or accessible")
915
+
916
+
917
  @app.route('/api/tutor/notes/<uuid:notes_id>/speak', methods=['POST'])
918
  def speak_notes(notes_id):
919
+ """
920
+ Generate TTS audio for notes using ElevenLabs (non-streaming),
921
+ combine chunks using pydub, and store the final MP3 in Supabase Storage.
922
+ Updates the note record with the audio URL and deducts credits.
923
+ """
924
+ if not PYDUB_AVAILABLE:
925
+ logging.error("Audio processing library (pydub/ffmpeg) check failed.")
926
+ return jsonify({'error': 'Server configuration error: Audio processing library not available.'}), 500
927
+
928
+ # 0. Authenticate User
929
  user, error = verify_token(request.headers.get('Authorization'))
930
  if error:
931
  return jsonify({'error': error['error']}), error['status']
932
 
933
  if not supabase or not elevenlabs_client:
934
+ logging.error("Backend service (Supabase or ElevenLabs client) not initialized.")
935
  return jsonify({'error': 'Backend service unavailable'}), 503
936
 
937
  try:
938
  # 1. Verify note ownership and get content
939
+ logging.info(f"Processing speak request for note {notes_id} by user {user.id}")
940
  note_res = supabase.table('notes') \
941
  .select('user_id, content, tts_audio_url') \
942
  .eq('id', str(notes_id)) \
 
945
  .execute()
946
 
947
  if not note_res.data:
948
+ logging.warning(f"Note {notes_id} not found or unauthorized for user {user.id}.")
949
  return jsonify({'error': 'Note not found or unauthorized'}), 404
950
 
951
  # 2. Check user status and credits
 
955
  .single() \
956
  .execute()
957
 
958
+ # Check for potential errors from profile fetch itself if needed
959
+ if not profile_res.data:
960
+ logging.error(f"Could not fetch profile for user {user.id}")
961
+ return jsonify({'error': 'Failed to retrieve user profile'}), 500
962
+
963
+ if profile_res.data.get('suspended'):
964
+ logging.warning(f"User {user.id} account is suspended.")
965
  return jsonify({'error': 'Account suspended'}), 403
 
 
966
 
967
+ current_credits = profile_res.data.get('credits', 0)
968
+ CREDIT_COST = 5
969
+ if current_credits < CREDIT_COST:
970
+ logging.warning(f"User {user.id} has insufficient credits ({current_credits}/{CREDIT_COST}).")
971
+ return jsonify({'error': f'Insufficient credits (Need {CREDIT_COST})'}), 402
972
+
973
+ # 3. Return existing audio if available (and skip generation/deduction)
974
+ existing_audio_url = note_res.data.get('tts_audio_url')
975
+ if existing_audio_url:
976
+ logging.info(f"Using existing audio URL for note {notes_id}: {existing_audio_url}")
977
  return jsonify({
978
  'success': True,
979
+ 'audio_url': existing_audio_url,
980
+ 'message': 'Using existing audio file',
981
+ 'remaining_credits': current_credits # Return current credits as none were deducted
982
  })
983
 
984
+ notes_content = note_res.data.get('content')
985
+ if not notes_content or not notes_content.strip():
986
+ logging.warning(f"Note {notes_id} content is empty.")
987
  return jsonify({'error': 'Notes content is empty'}), 400
988
 
989
+ # 4. Generate TTS Audio with chunking (Non-Streaming) and combine with pydub
990
+ # ElevenLabs v2 non-streaming limit is often around 2500 chars, but check docs.
991
+ CHUNK_SIZE = 2500
992
+ text_chunks = [notes_content[i:i+CHUNK_SIZE] for i in range(0, len(notes_content), CHUNK_SIZE)]
993
+
994
+ combined_audio_segment = None
995
+ logging.info(f"Generating audio for note {notes_id} in {len(text_chunks)} chunks.")
996
+
997
+ for i, chunk in enumerate(text_chunks):
998
  try:
999
+ logging.debug(f"Generating audio for chunk {i+1}/{len(text_chunks)}...")
1000
+ # Use stream=False (default) for non-streaming generation
1001
+ chunk_audio_bytes = elevenlabs_client.generate(
1002
+ text=chunk.strip(), # Ensure no leading/trailing whitespace in chunk
1003
+ voice="Rachel", # Or your desired voice ID
1004
+ model="eleven_multilingual_v2" # Or your desired model ID
1005
  )
1006
+
1007
+ if not chunk_audio_bytes:
1008
+ logging.warning(f"ElevenLabs returned empty audio for chunk {i+1} of note {notes_id}")
1009
+ continue # Skip this chunk, maybe log or handle differently if needed
1010
+
1011
+ # Load chunk audio bytes into pydub AudioSegment using BytesIO
1012
+ segment = AudioSegment.from_file(BytesIO(chunk_audio_bytes), format="mp3")
1013
+
1014
+ # Combine segments
1015
+ if combined_audio_segment is None:
1016
+ combined_audio_segment = segment
1017
+ else:
1018
+ combined_audio_segment += segment # Append segment
1019
+ logging.debug(f"Successfully processed chunk {i+1}/{len(text_chunks)}")
1020
+
1021
+ except ImportError as e:
1022
+ logging.error(f"pydub/ffmpeg error during chunk processing: {e}")
1023
+ raise e # Re-raise to be caught by the outer ImportError handler
1024
  except Exception as e:
1025
+ logging.error(f"Error generating/processing audio chunk {i+1} for note {notes_id}: {str(e)}")
1026
+ # Stop the process if a chunk fails
1027
+ raise RuntimeError(f"Audio generation/processing failed for chunk {i+1}: {str(e)}")
1028
 
1029
+ if combined_audio_segment is None:
1030
+ # This could happen if all chunks failed or the content was only whitespace
1031
+ logging.error(f"Failed to generate any audio content for note {notes_id}.")
1032
+ raise RuntimeError("Failed to generate any audio content.")
1033
+
1034
+ # Export combined audio to final bytes
1035
+ output_bytes_io = BytesIO()
1036
+ combined_audio_segment.export(output_bytes_io, format="mp3")
1037
+ final_audio_bytes = output_bytes_io.getvalue() # Get the raw 'bytes' data
1038
+
1039
+ if not final_audio_bytes:
1040
+ logging.error(f"Generated empty final audio file after combining chunks for note {notes_id}.")
1041
+ raise RuntimeError("Generated empty final audio file after combining chunks.")
1042
+
1043
+ logging.info(f"Audio generation complete for note {notes_id}. Total size: {len(final_audio_bytes)} bytes.")
1044
 
1045
  # 5. Save to Supabase Storage
1046
+ bucket_name = 'notes-audio' # Ensure this bucket exists and has correct policies
1047
+ # Use user ID and note ID for a unique, organized path
1048
  file_path = f'{user.id}/{str(notes_id)}.mp3'
1049
+ audio_url = None # Initialize audio_url
1050
+
1051
  try:
1052
+ logging.info(f"Uploading audio to Supabase Storage: {bucket_name}/{file_path}")
1053
+ # Upload the final combined audio bytes. Use upsert=true to overwrite if regenerating.
1054
+ supabase.storage.from_(bucket_name).upload(
1055
  path=file_path,
1056
+ file=final_audio_bytes, # Pass the raw 'bytes' object
1057
+ file_options={"content-type": "audio/mpeg", "upsert": "true"}
1058
  )
1059
+ # Note: supabase-py v1 might raise StorageException on failure.
1060
+ # v2 might return a response object to check. Adapt error checking if needed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1061
 
1062
+ # Get public URL (make sure RLS policies allow public reads or generate signed URL)
1063
+ public_url_data = supabase.storage.from_(bucket_name).get_public_url(file_path)
1064
+ # Assuming the URL is directly in the response data
1065
+ audio_url = public_url_data
 
1066
 
1067
+ if not audio_url:
1068
+ # This case indicates an issue with getting the URL after a successful upload
1069
+ logging.error(f"Upload to {file_path} seemed successful, but failed to get public URL.")
1070
+ raise ConnectionError("Failed to retrieve audio URL after upload.")
 
 
 
 
 
1071
 
1072
+ logging.info(f"Audio uploaded successfully for note {notes_id}. URL: {audio_url}")
1073
+
1074
+ # --- Database Updates and Credit Deduction ---
1075
+ # Wrap these in a try/except block for potential rollback on failure
1076
+ try:
1077
+ # 6. Update notes table with the audio URL
1078
+ logging.debug(f"Updating notes table for note {notes_id} with URL.")
1079
+ update_res = supabase.table('notes') \
1080
+ .update({'tts_audio_url': audio_url}) \
1081
+ .eq('id', str(notes_id)) \
1082
+ .eq('user_id', user.id) \
1083
+ .execute()
1084
+
1085
+ # Basic check if response indicates data was modified (adapt based on client version)
1086
+ if not update_res.data:
1087
+ logging.warning(f"Note update query executed for {notes_id} but no data returned (might be ok, or indicate issue).")
1088
+ # Consider stronger checks based on specific client behavior on error/no-update
1089
+
1090
+ # 7. Deduct credits
1091
+ new_credits = current_credits - CREDIT_COST
1092
+ logging.debug(f"Deducting {CREDIT_COST} credits for user {user.id}. New balance: {new_credits}")
1093
+ credit_res = supabase.table('profiles') \
1094
+ .update({'credits': new_credits}) \
1095
+ .eq('id', user.id) \
1096
+ .execute()
1097
+
1098
+ # Basic check for credit update
1099
+ if not credit_res.data:
1100
+ # CRITICAL: Failed to deduct credits after upload/URL update.
1101
+ logging.error(f"CRITICAL: Failed to deduct credits for user {user.id} after audio generation for note {notes_id}.")
1102
+ # Decide handling: Log and proceed? Attempt rollback?
1103
+ # For now, log error and return success as audio is generated, but flag the inconsistency.
1104
+ # Ideally, implement transactional logic or robust cleanup.
1105
+
1106
+ logging.info(f"Successfully updated database and deducted credits for note {notes_id}")
1107
+
1108
+ return jsonify({
1109
+ 'success': True,
1110
+ 'audio_url': audio_url,
1111
+ 'remaining_credits': new_credits
1112
+ })
1113
+
1114
+ except Exception as db_error:
1115
+ # Error occurred during DB update/credit deduction AFTER successful upload
1116
+ logging.error(f"Database update/credit deduction failed for note {notes_id} AFTER upload: {str(db_error)}. URL was {audio_url}")
1117
+ logging.info(f"Attempting to clean up uploaded file: {file_path}")
1118
+ # Attempt to clean up the uploaded file since DB update failed
1119
+ try:
1120
+ supabase.storage.from_(bucket_name).remove([file_path])
1121
+ logging.info(f"Successfully cleaned up orphaned file: {file_path}")
1122
+ except Exception as cleanup_error:
1123
+ logging.error(f"Failed to clean up orphaned file {file_path} after DB error: {cleanup_error}")
1124
+ # Re-raise the database error to signal the overall operation failed
1125
+ raise db_error
1126
+
1127
+
1128
+ except Exception as upload_db_error:
1129
+ # This catches errors during upload OR the subsequent DB operations block if re-raised
1130
+ logging.error(f"Error during upload or DB update phase for note {notes_id}: {str(upload_db_error)}")
1131
+ # Attempt cleanup if file might have been uploaded and URL obtained before the error
1132
+ if audio_url: # Check if upload likely succeeded before the error
1133
+ try:
1134
+ logging.info(f"Attempting cleanup for failed operation: {file_path}")
1135
+ supabase.storage.from_(bucket_name).remove([file_path])
1136
+ logging.info(f"Cleanup successful for {file_path}")
1137
+ except Exception as cleanup_error:
1138
+ # Log if cleanup also fails, but report the original error
1139
+ logging.error(f"Upload/DB error occurred, AND cleanup failed for {file_path}: {cleanup_error}")
1140
+
1141
+ # Re-raise the original error that caused the failure
1142
+ raise upload_db_error
1143
+
1144
+ except ImportError as e:
1145
+ # Catch the specific ImportError from the pydub check/usage
1146
+ logging.error(f"Missing dependency error: {e}")
1147
+ return jsonify({'error': 'Server configuration error: Audio library (pydub/ffmpeg) missing or failed.'}), 500
1148
+ except (RuntimeError, ConnectionError) as e:
1149
+ # Catch specific errors we raised for generation/upload/db issues
1150
+ logging.error(f"Operation failed for note {notes_id}: {str(e)}")
1151
+ return jsonify({'error': str(e)}), 500 # Return the specific error message
1152
  except Exception as e:
1153
+ # Catch any other unexpected errors
1154
+ logging.error(f"Unexpected speak endpoint error for note {notes_id}: {traceback.format_exc()}")
1155
+ # Return a generic error message to the client for unknown errors
1156
+ return jsonify({'error': 'An unexpected error occurred during audio generation.'}), 500
1157
+
1158
 
1159
  # New endpoint to view existing audio URL
1160
  @app.route('/api/tutor/notes/<uuid:notes_id>/audio', methods=['GET'])