fix:audio generation off main thread

This commit is contained in:
dave 2025-04-04 11:33:24 +03:00
parent 003fac544c
commit 2008ef64fc
4 changed files with 152 additions and 122 deletions
app/src/main/java/com/voca/app

@ -192,23 +192,21 @@ class Mp3PlayerRepositoryImpl(
override fun resume() {
Timber.d("Calling RESUME on service via Intent")
val serviceIntent = Intent(applicationContext, Mp3PlaybackService::class.java).apply {
action = Mp3PlaybackService.ACTION_PLAY // Resume is often handled by PLAY action if paused
// If service needs the current URI for resume, ensure it's passed if necessary.
// Assuming service retains current URI, ACTION_PLAY is sufficient.
// Alternatively, create a dedicated ACTION_RESUME if needed.
_playbackState.value.currentUri?.let {
putExtra(Mp3PlaybackService.EXTRA_MEDIA_URI, it.toString())
}
}
try {
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (e: Exception) {
Timber.e(e, "Error sending RESUME/PLAY intent to service")
_playbackState.value = _playbackState.value.copy(error = "Error resuming: ${e.message}")
}
// Also call via binder if bound
serviceBinder?.getService()?.resume()
val serviceIntent = Intent(applicationContext, Mp3PlaybackService::class.java).apply {
action = Mp3PlaybackService.ACTION_RESUME // Use explicit resume action
}
try {
// Start service (needed if service stopped/killed while paused)
ContextCompat.startForegroundService(applicationContext, serviceIntent)
Timber.i("--> [Repo] resume: startForegroundService call SUCCEEDED")
} catch (e: Exception) {
Timber.e(e, "--> [Repo] resume: startForegroundService call FAILED")
Timber.e(e, "Error ensuring service is running for resume")
_playbackState.value = _playbackState.value.copy(error = "Error resuming playback service: ${e.message}")
// Optionally return or just let the service handle potential errors if already running?
}
// Rely on the intent to be processed by the service's onStartCommand
Timber.d("Service notified with ACTION_RESUME. State will update via observer.")
}
override fun seekTo(positionMs: Long) {

@ -35,7 +35,8 @@ import com.voca.app.BuildConfig
import com.voca.app.data.repository.FileSaverRepository.Companion.VOCA_SUBFOLDER
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.flowOn
/**
* Use case responsible for generating an MP3 file from the currently loaded document content.
@ -97,7 +98,11 @@ class GenerateMp3UseCase(
logWithTimestamp("Step 2: Starting Text-to-WAV conversion. Text length: ${fullText.length} chars")
val ttsStartTime = System.currentTimeMillis()
try {
// Add extra logging inside collection
logWithTimestamp("DEBUG: About to collect textToWavConverter flow.")
textToWavConverter.convertTextToWav(context, fullText, fileNameBase).collectLatest { progress ->
// Log received progress immediately
logWithTimestamp("DEBUG: Received TtsWavProgress: ${progress::class.simpleName}")
when (progress) {
is TtsWavProgress.InProgress -> {
val scaledProgress = progress.progress * 0.5f
@ -115,17 +120,20 @@ class GenerateMp3UseCase(
}
is TtsWavProgress.Error -> {
Timber.e(progress.cause, "Text-to-WAV conversion FAILED after ${(System.currentTimeMillis() - ttsStartTime)/1000}s: ${progress.message}")
// Throw exception to be caught by the outer try-catch
throw IOException("Text-to-WAV failed: ${progress.message}", progress.cause)
}
}
}
logWithTimestamp("DEBUG: Finished collecting textToWavConverter flow normally.")
} catch (e: Exception) {
Timber.e(e, "GENERATE_MP3_USE_CASE_ERROR: Error collecting from TextToWavConverter flow after ${(System.currentTimeMillis() - ttsStartTime)/1000}s")
// Catch exceptions from the collection itself or rethrown from TtsWavProgress.Error
Timber.e(e, "GENERATE_MP3_USE_CASE_ERROR: Error during TextToWavConverter flow collection after ${(System.currentTimeMillis() - ttsStartTime)/1000}s")
send(Mp3GenerationProgress.Error("Text-to-WAV failed: ${e.message}", e))
return@channelFlow
}
logWithTimestamp("DEBUG: Finished collecting from textToWavConverter flow.")
logWithTimestamp("DEBUG: Passed TextToWavConverter collection block.")
// Ensure WAV file and timing metadata were created
val currentWavFile = wavFile ?: run {
@ -134,8 +142,8 @@ class GenerateMp3UseCase(
return@channelFlow
}
val currentTimingMetadata = timingMetadata ?: run {
Timber.w("TimingMetadata was null after successful TTS conversion. Proceeding without timings.")
TimingMetadata(sentences = emptyList())
Timber.w("TimingMetadata was null after successful TTS conversion. Proceeding without timings.")
TimingMetadata(sentences = emptyList())
}
if (currentWavFile.length() == 0L) {
@ -148,102 +156,104 @@ class GenerateMp3UseCase(
logWithTimestamp("Step 3: Starting WAV-to-MP3 encoding with FFmpeg. WAV file size: ${currentWavFile.length()} bytes")
val encodingStartTime = System.currentTimeMillis()
try {
logWithTimestamp("DEBUG: About to call lameEncoder.encodeWavToMp3")
logWithTimestamp("DEBUG: About to collect lameEncoder.encodeWavToMp3 flow.")
lameEncoder.encodeWavToMp3(currentWavFile, deleteWavOnSuccess = true).collectLatest { progress ->
logWithTimestamp("DEBUG: Received EncodingProgress: ${progress::class.simpleName}")
when (progress) {
is EncodingProgress.InProgress -> {
val scaledProgress = 0.5f + (progress.progress * 0.5f)
val elapsed = System.currentTimeMillis() - encodingStartTime
logWithTimestamp("Encoding progress: ${(progress.progress * 100).toInt()}% (${elapsed/1000}s) - ${progress.stage}")
send(Mp3GenerationProgress.InProgress(scaledProgress.coerceIn(0.5f, 1.0f), "MP3: ${progress.stage}"))
logWithTimestamp("MP3 encoding progress: ${(progress.progress * 100).toInt()}% (${elapsed/1000}s) - ${progress.stage}")
send(Mp3GenerationProgress.InProgress(scaledProgress.coerceIn(0f, 1.0f), "Encoding: ${progress.stage}"))
}
is EncodingProgress.Success -> {
mp3File = progress.mp3File
val encodingTime = System.currentTimeMillis() - encodingStartTime
val safeFile = mp3File ?: throw IOException("MP3 encoding generated a null file")
if (safeFile.length() == 0L) { throw IOException("MP3 encoding generated an empty file") }
logWithTimestamp("WAV-to-MP3 encoding SUCCESSFUL after ${encodingTime/1000}s: ${safeFile.absolutePath}, size: ${safeFile.length()} bytes")
// 4. Save Timing Metadata as JSON
var jsonFileUri: Uri? = null
logWithTimestamp("Step 4: Saving timing metadata as JSON companion file.")
try {
val jsonFile = File(safeFile.parent, "${safeFile.nameWithoutExtension}.json")
val jsonString = json.encodeToString(currentTimingMetadata)
withContext(Dispatchers.IO) {
jsonFile.writeText(jsonString)
}
logWithTimestamp("Saved timing metadata to ${jsonFile.absolutePath}, size: ${jsonFile.length()} bytes")
// Create Content URI for the JSON file
jsonFileUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
jsonFile
)
} catch (e: Exception) {
Timber.e(e, "Failed to save timing metadata JSON file. Proceeding without it.")
}
// 5. Create Content URI using FileProvider
val mp3FileUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
safeFile
)
Timber.i("Generated MP3 Content URI: $mp3FileUri")
Timber.i("Generated JSON Content URI: $jsonFileUri")
// 6. Update the repository with the temporary MP3 path (optional, maybe remove if URI is enough)
// documentRepository.updateLastGeneratedMp3Path(safeFile.absolutePath)
// 7. Emit Success with the Content URI
send(Mp3GenerationProgress.Success(mp3FileUri, jsonFileUri))
logWithTimestamp("MP3 GENERATION COMPLETE.")
logWithTimestamp("WAV-to-MP3 encoding SUCCESSFUL after ${encodingTime/1000}s: ${mp3File?.absolutePath}, size: ${mp3File?.length() ?: 0} bytes")
send(Mp3GenerationProgress.InProgress(1.0f, "MP3 encoding complete."))
}
is EncodingProgress.Error -> {
val encodingTime = System.currentTimeMillis() - encodingStartTime
Timber.e(progress.cause, "WAV-to-MP3 encoding FAILED after ${encodingTime/1000}s: ${progress.message}")
throw IOException("WAV-to-MP3 failed: ${progress.message}", progress.cause)
Timber.e(progress.cause, "WAV-to-MP3 encoding FAILED after ${(System.currentTimeMillis() - encodingStartTime)/1000}s: ${progress.message}")
throw IOException("MP3 encoding failed: ${progress.message}", progress.cause)
}
}
}
logWithTimestamp("DEBUG: Finished collecting lameEncoder.encodeWavToMp3 flow normally.")
} catch (e: Exception) {
val encodingTime = System.currentTimeMillis() - encodingStartTime
Timber.e(e, "Error in FFmpeg encoder flow after ${encodingTime/1000}s: ${e.message}")
send(Mp3GenerationProgress.Error("WAV-to-MP3 or metadata saving failed: ${e.message}", e))
// Safely clean up WAV file
currentWavFile.takeIf { it.exists() }?.let { safeWav ->
logWithTimestamp("Cleaning up WAV file after encoding error: ${safeWav.absolutePath}")
safeWav.delete()
}
// Safely clean up potentially created MP3 file
mp3File?.takeIf { it.exists() }?.let { safeMp3 ->
logWithTimestamp("Cleaning up MP3 file after encoding error: ${safeMp3.absolutePath}")
safeMp3.delete()
}
Timber.e(e, "GENERATE_MP3_USE_CASE_ERROR: Error during lameEncoder flow collection after ${(System.currentTimeMillis() - encodingStartTime)/1000}s")
send(Mp3GenerationProgress.Error("MP3 encoding failed: ${e.message}", e))
return@channelFlow
}
logWithTimestamp("DEBUG: Passed lameEncoder collection block.")
// Ensure MP3 file was created
val finalMp3File = mp3File ?: run {
Timber.e("MP3 file was null after successful encoding.")
send(Mp3GenerationProgress.Error("Internal error: MP3 file not generated."))
return@channelFlow
}
if (finalMp3File.length() == 0L) {
Timber.e("Generated MP3 file is empty (0 bytes): ${finalMp3File.absolutePath}")
send(Mp3GenerationProgress.Error("FFmpeg generated an empty MP3 file."))
return@channelFlow
}
// 4. (Optional) Save timing metadata alongside MP3 if needed
// Example: Save to a .json file with the same base name
val timingsFile = File(finalMp3File.parent, "${finalMp3File.nameWithoutExtension}.json")
try {
logWithTimestamp("Step 4: Saving sentence timings to ${timingsFile.absolutePath}")
val jsonString = json.encodeToString(currentTimingMetadata)
timingsFile.writeText(jsonString)
logWithTimestamp("Sentence timings saved successfully.")
} catch (e: Exception) {
Timber.e(e, "Failed to save sentence timings JSON file.")
// Decide if this is a fatal error or just a warning
// send(Mp3GenerationProgress.Error("Failed to save timings: ${e.message}", e))
// return@channelFlow // Changed back
}
val totalTime = System.currentTimeMillis() - startTime
logWithTimestamp("Step 5: MP3 Generation SUCCESSFUL! Total time: ${totalTime / 1000}s. Output: ${finalMp3File.absolutePath}")
// Re-introduce FileProvider to get URIs
val mp3FileUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
finalMp3File
)
var timingsFileUri: Uri? = null
try {
timingsFileUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
timingsFile
)
logWithTimestamp("Got timings file URI: $timingsFileUri")
} catch (e: Exception) {
Timber.e(e, "Failed to get FileProvider URI for timings file, sending null.")
}
send(Mp3GenerationProgress.Success(mp3FileUri, timingsFileUri)) // Send URIs
} catch (e: CancellationException) {
Timber.w(e, "MP3 Generation Job Cancelled after ${(System.currentTimeMillis() - startTime) / 1000}s.")
send(Mp3GenerationProgress.Cancelled)
// Clean up intermediate files if cancelled?
wavFile?.delete()
mp3File?.delete()
throw e // Rethrow cancellation
} catch (e: Exception) {
val totalTime = System.currentTimeMillis() - startTime
Timber.e(e, "FATAL ERROR in GenerateMp3UseCase after ${totalTime/1000}s: ${e.message}")
send(Mp3GenerationProgress.Error("MP3 Generation failed: ${e.message}", e))
// Safely clean up potentially created WAV file
wavFile?.takeIf { it.exists() }?.let { safeWav ->
logWithTimestamp("Cleaning up WAV file after fatal error: ${safeWav.absolutePath}")
safeWav.delete()
}
// Also attempt cleanup for mp3File in the fatal error case
mp3File?.takeIf { it.exists() }?.let { safeMp3 ->
logWithTimestamp("Cleaning up MP3 file after fatal error: ${safeMp3.absolutePath}")
safeMp3.delete()
}
Timber.e(e, "MP3 Generation FAILED after ${totalTime / 1000}s.")
send(Mp3GenerationProgress.Error("MP3 generation failed: ${e.message}", e))
// Clean up intermediate files on error
wavFile?.delete()
mp3File?.delete()
}
}
}.flowOn(Dispatchers.IO) // Apply flowOn operator HERE
}
// Helper to get document file name (implementation detail, might move to repo impl)
private fun IDocumentRepository.getCurrentDocumentFileName(): String? {

@ -61,6 +61,7 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
const val ACTION_PLAY = "com.voca.app.service.ACTION_PLAY"
const val ACTION_PAUSE = "com.voca.app.service.ACTION_PAUSE"
const val ACTION_STOP = "com.voca.app.service.ACTION_STOP"
const val ACTION_RESUME = "com.voca.app.service.ACTION_RESUME"
const val ACTION_SEEK_TO = "com.voca.app.service.ACTION_SEEK_TO"
const val EXTRA_SEEK_POSITION = "com.voca.app.service.EXTRA_SEEK_POSITION"
const val EXTRA_MEDIA_URI = "com.voca.app.service.EXTRA_MEDIA_URI"
@ -120,19 +121,31 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
if (uri != null) {
Timber.d("onStartCommand: Handling PLAY action for $uri")
Timber.i("--> [Service] onStartCommand: Handling ACTION_PLAY.")
// Check if already playing this URI or if paused
if (uri != currentUri || mediaPlayer == null || !isMediaPlayerPrepared) {
Timber.i("--> [Service] onStartCommand: Calling startPlayback for new/different URI.")
startPlayback(uri)
} else if (mediaPlayer?.isPlaying == false && isMediaPlayerPrepared) {
Timber.i("--> [Service] onStartCommand: Calling resumePlayback for existing URI.")
resumePlayback() // If same URI but paused, resume
// --- Revised Logic ---
if (uri == currentUri && mediaPlayer != null && isMediaPlayerPrepared) {
// URI matches, player exists and is prepared
if (_playbackState.value.isPaused) { // Check our state flow
Timber.i("--> [Service] onStartCommand(PLAY): Resuming playback for existing URI via resumePlayback().")
resumePlayback()
} else if (_playbackState.value.isPlaying) {
Timber.i("--> [Service] onStartCommand(PLAY): Already playing URI $uri. Ignoring.")
// Ensure foreground and notification are correct
startForegroundServiceIfNeeded()
updateNotification()
} else {
// Player exists and prepared, but not playing/paused (e.g., error state? completed?)
// Restart playback in this edge case.
Timber.w("--> [Service] onStartCommand(PLAY): Received PLAY for existing prepared URI $uri, but not paused/playing. State: ${_playbackState.value}. Restarting.")
startPlayback(uri) // Restart
}
} else {
Timber.d("onStartCommand: PLAY action for URI $uri, but already playing or preparing.")
// Optional: Update notification if needed, ensure foreground
startForegroundServiceIfNeeded()
// URI is different, or player needs initialization
Timber.i("--> [Service] onStartCommand(PLAY): Starting playback for new/unprepared URI: $uri")
startPlayback(uri)
}
// --- End Revised Logic ---
} else {
Timber.w("onStartCommand: PLAY action received without a valid URI.")
// Stop if started invalidly without a URI to play
@ -144,6 +157,11 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
Timber.i("--> [Service] onStartCommand: Handling ACTION_PAUSE.")
pausePlayback()
}
ACTION_RESUME -> {
Timber.d("onStartCommand: Handling RESUME action")
Timber.i("--> [Service] onStartCommand: Handling ACTION_RESUME.")
resumePlayback()
}
ACTION_STOP -> {
Timber.d("onStartCommand: Handling STOP action")
Timber.i("--> [Service] onStartCommand: Handling ACTION_STOP.")

@ -354,10 +354,8 @@ fun MainScreen(
mp3HighlightedSentenceId = displayMp3HighlightedId,
ttsSentenceIndex = if (!isMp3FlowActive) ttsState.currentSentenceIndex else -1, // Only use TTS index if MP3 not active
ttsSentences = if (!isMp3FlowActive) ttsState.sentences else emptyList(), // Only use TTS sentences if MP3 not active
modifier = Modifier
.fillMaxSize() // Fill the parent Box
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp) // Add some internal padding if needed
modifier = Modifier.fillMaxSize() // Let FullTextDisplayArea handle internal styling
// Removed border and padding from here
)
} else {
OutlinedTextField(
@ -365,9 +363,10 @@ fun MainScreen(
onValueChange = { viewModel.onAction(MainAction.UpdateInputText(it)) },
modifier = Modifier
.fillMaxSize(), // Fill the entire parent Box
// Remove explicit border and padding, use inherent outline
label = { Text("Enter text to read or convert") },
readOnly = false
readOnly = false,
// Apply a larger text style for consistency
textStyle = MaterialTheme.typography.headlineSmall
)
}
}
@ -759,8 +758,8 @@ fun Mp3PlayerControls(
)
}
// Show generate button (or similar action) only if not processing AND generation is enabled
if (!isProcessing && generationEnabled) {
// Show generate button ONLY if NOT processing, NOT ready, NOT loading, NO error AND generation IS enabled
if (!isProcessing && !isMp3Ready && !isLoading && !hasError && generationEnabled) {
Button(onClick = onGenerateMp3) {
Text("Create MP3")
}
@ -795,9 +794,13 @@ fun FullTextDisplayArea(
// Get the final index to highlight, preferring MP3 ID if available
val highlightIndex by remember(mp3HighlightedSentenceId, ttsSentenceIndex) {
derivedStateOf {
mp3HighlightedSentenceId?.toIntOrNull() // Convert MP3 String ID to Int
?: ttsSentenceIndex.takeIf { it != -1 } // Fallback to TTS index
?: -1 // No highlight
// Disabled MP3 highlighting for now as word tracking is not ready
// mp3HighlightedSentenceId?.toIntOrNull() // Convert MP3 String ID to Int
// ?: ttsSentenceIndex.takeIf { it != -1 } // Fallback to TTS index
// ?: -1 // No highlight
// Only use TTS index for highlighting
ttsSentenceIndex.takeIf { it != -1 } ?: -1
}
}
@ -916,10 +919,11 @@ fun FullTextDisplayArea(
// Using BasicText with annotatedString for highlighting
BasicText(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge.copy(
// Use a larger, theme-based style for consistency
style = MaterialTheme.typography.headlineSmall.copy(
color = MaterialTheme.colorScheme.onBackground,
fontSize = 18.sp, // Example size, consider making configurable
lineHeight = 24.sp // Add some line spacing for readability
// Optional: Adjust line height if needed for the larger font
lineHeight = MaterialTheme.typography.headlineSmall.lineHeight * 1.2
)
)
}