mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
refactor: voice recordings on okio paths; host ports for the note composer
- RecordingResult/AnonymizedResult move to commons with okio Path files (commons now exposes okio as api); VoiceAnonymizationController moves to commonsUI and re-voices through AccountViewModelHost.anonymizeVoice. - mediaUriOfFile expect/actual replaces Uri.fromFile in the voice uploads. - AccountViewModelHost gains scheduledPostStore and createWritingAssistant; ShortNotePostViewModel no longer reads Amethyst.instance, Context, UUID or System time. AMETHYST_CLIENT_TAG_NAME moves to commons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
+2
-1
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.GeohashIdentityStore
|
||||
@@ -419,7 +420,7 @@ class AccountCacheState(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CLIENT_TAG_NAME = "Amethyst"
|
||||
const val CLIENT_TAG_NAME = AMETHYST_CLIENT_TAG_NAME
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.record_a_message_description
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ToggleableBox
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.audio.ExpandingCirclesAnimation
|
||||
import com.vitorpamplona.amethyst.commons.audio.FloatingRecordingIndicator
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
|
||||
+3
-14
@@ -25,6 +25,7 @@ import android.media.MediaCodecInfo
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult
|
||||
import com.vitorpamplona.amethyst.commons.audio.PitchShifter
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -33,24 +34,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Result of voice anonymization processing.
|
||||
*
|
||||
* @property file The output audio file (AAC in MP4 container)
|
||||
* @property waveform Amplitude data for waveform visualization (one value per second)
|
||||
* @property duration Audio duration in seconds
|
||||
*/
|
||||
data class AnonymizedResult(
|
||||
val file: File,
|
||||
val waveform: List<Float>,
|
||||
val duration: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Processes audio files to alter voice characteristics for privacy.
|
||||
*
|
||||
@@ -109,7 +98,7 @@ class VoiceAnonymizer {
|
||||
}
|
||||
|
||||
onProgress(1f)
|
||||
Result.success(AnonymizedResult(outputFile, waveform, duration))
|
||||
Result.success(AnonymizedResult(outputFile.toOkioPath(), waveform, duration))
|
||||
} catch (e: CancellationException) {
|
||||
// Let cancellation propagate so structured concurrency works (e.g. the
|
||||
// upload screen being dismissed cancels this job).
|
||||
|
||||
+7
-6
@@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -64,12 +65,12 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.File
|
||||
import okio.Path
|
||||
|
||||
@Composable
|
||||
fun VoiceMessagePreview(
|
||||
voiceMetadata: AudioMeta,
|
||||
localFile: File? = null,
|
||||
localFile: Path? = null,
|
||||
onRemove: () -> Unit,
|
||||
onReRecord: ((RecordingResult) -> Unit)? = null,
|
||||
isUploading: Boolean = false,
|
||||
@@ -263,7 +264,7 @@ private fun ReRecordButton(
|
||||
@Composable
|
||||
private fun ManageMediaPlayer(
|
||||
voiceMetadata: AudioMeta,
|
||||
localFile: File?,
|
||||
localFile: Path?,
|
||||
onCompletion: () -> Unit,
|
||||
onPlayerChanged: (MediaPlayer?) -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
@@ -371,12 +372,12 @@ private fun handleWaveformScrub(
|
||||
|
||||
private fun createMediaPlayer(
|
||||
url: String,
|
||||
localFile: File?,
|
||||
localFile: Path?,
|
||||
): MediaPlayer? =
|
||||
try {
|
||||
MediaPlayer().apply {
|
||||
if (localFile != null && localFile.exists()) {
|
||||
setDataSource(localFile.absolutePath)
|
||||
if (localFile != null && localFile.toFile().exists()) {
|
||||
setDataSource(localFile.toString())
|
||||
} else {
|
||||
setDataSource(url)
|
||||
}
|
||||
|
||||
+3
-8
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import androidx.media3.common.MimeTypes
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -35,15 +36,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
|
||||
class RecordingResult(
|
||||
val file: File,
|
||||
val mimeType: String,
|
||||
val amplitudes: List<Float>,
|
||||
val duration: Int,
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class VoiceMessageRecorder {
|
||||
@Volatile
|
||||
@@ -145,7 +140,7 @@ class VoiceMessageRecorder {
|
||||
|
||||
return if (duration >= 1 && file != null) {
|
||||
RecordingResult(
|
||||
file,
|
||||
file.toOkioPath(),
|
||||
MimeTypes.AUDIO_AAC,
|
||||
amplitudesCopy,
|
||||
duration,
|
||||
|
||||
@@ -756,7 +756,7 @@ fun ReplyViaVoiceReaction(
|
||||
nav.nav {
|
||||
Route.VoiceReply(
|
||||
replyToNoteId = baseNote.idHex,
|
||||
recordingFilePath = audio.file.absolutePath,
|
||||
recordingFilePath = audio.file.toString(),
|
||||
mimeType = audio.mimeType,
|
||||
duration = audio.duration,
|
||||
amplitudes = Json.encodeToString(audio.amplitudes),
|
||||
|
||||
+17
@@ -25,7 +25,11 @@ import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.AppModules
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
|
||||
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
|
||||
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
|
||||
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
|
||||
@@ -33,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
|
||||
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModelHost
|
||||
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent
|
||||
import com.vitorpamplona.amethyst.service.uploads.AndroidMediaUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizer
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
@@ -49,6 +55,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import okio.Path
|
||||
|
||||
/** [AccountViewModelHost] over the app's [AppModules]. Lazy services are read on first use. */
|
||||
class AndroidAccountViewModelHost(
|
||||
@@ -77,6 +84,16 @@ class AndroidAccountViewModelHost(
|
||||
|
||||
override val deviceLocation: DeviceLocation get() = modules.locationManager
|
||||
|
||||
override val scheduledPostStore: ScheduledPostStore get() = modules.scheduledPostStore
|
||||
|
||||
override suspend fun anonymizeVoice(
|
||||
input: Path,
|
||||
presetName: String,
|
||||
): Result<AnonymizedResult> = VoiceAnonymizer().anonymize(input.toFile(), VoicePreset.valueOf(presetName))
|
||||
|
||||
// Built on the application context: the assistant outlives any one Activity inside the ViewModel.
|
||||
override fun createWritingAssistant(): WritingAssistant = WritingAssistantFactory.create(modules.appContext)
|
||||
|
||||
override val savedAccounts: Flow<Set<HexKey>> =
|
||||
LocalPreferences
|
||||
.accountsFlow()
|
||||
|
||||
+1
-1
@@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
|
||||
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
@@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
|
||||
|
||||
+6
-6
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.audio.WaveformData
|
||||
import com.vitorpamplona.amethyst.commons.chats.ui.AutoScrollToNewest
|
||||
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager
|
||||
@@ -121,7 +122,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
|
||||
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
@@ -219,7 +219,7 @@ private fun CordnGroupChat(
|
||||
// it after sending. A note recorded and then abandoned — the screen closed,
|
||||
// the room switched — never reached that, so it stayed on disk: an
|
||||
// unencrypted copy of a message that was never even sent.
|
||||
pendingVoice?.file?.delete()
|
||||
pendingVoice?.file?.toFile()?.delete()
|
||||
}
|
||||
}
|
||||
var attachError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -595,11 +595,11 @@ private fun CordnGroupChat(
|
||||
onVoiceNote = { recording ->
|
||||
// Re-recording replaces what was there; the old file is a temp
|
||||
// file this screen owns, so it goes now rather than being leaked.
|
||||
pendingVoice?.file?.delete()
|
||||
pendingVoice?.file?.toFile()?.delete()
|
||||
pendingVoice = recording
|
||||
},
|
||||
onRemoveVoice = {
|
||||
pendingVoice?.file?.delete()
|
||||
pendingVoice?.file?.toFile()?.delete()
|
||||
pendingVoice = null
|
||||
},
|
||||
attaching = attaching,
|
||||
@@ -1311,7 +1311,7 @@ private suspend fun sendVoiceNote(
|
||||
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
|
||||
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() }
|
||||
val bytes = withContext(Dispatchers.IO) { recording.file.toFile().readBytes() }
|
||||
val tag =
|
||||
CordnMediaService(accountViewModel.account)
|
||||
.upload(
|
||||
@@ -1331,7 +1331,7 @@ private suspend fun sendVoiceNote(
|
||||
// invisible to the person who sent it.
|
||||
room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag)))
|
||||
} finally {
|
||||
withContext(Dispatchers.IO) { recording.file.delete() }
|
||||
withContext(Dispatchers.IO) { recording.file.toFile().delete() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -63,7 +63,6 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -192,7 +191,6 @@ fun ShortNotePostScreen(
|
||||
postViewModel.init(accountViewModel)
|
||||
postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl)
|
||||
|
||||
val context = LocalContext.current
|
||||
val mediaResolver = rememberSharedMediaResolver()
|
||||
|
||||
val proposeAiImprovements by
|
||||
@@ -201,8 +199,7 @@ fun ShortNotePostScreen(
|
||||
|
||||
LaunchedEffect(proposeAiImprovements) {
|
||||
if (proposeAiImprovements == BooleanType.ALWAYS) {
|
||||
// The assistant outlives this Activity inside the ViewModel, so it must not hold it.
|
||||
postViewModel.initWritingAssistant(context.applicationContext)
|
||||
postViewModel.initWritingAssistant()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-21
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
@@ -33,8 +32,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
|
||||
import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.BooleanType
|
||||
@@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.mediaUriOfFile
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience
|
||||
@@ -89,11 +91,8 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
|
||||
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
|
||||
import com.vitorpamplona.amethyst.commons.util.platformFileSystem
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
@@ -188,7 +187,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import okio.Path
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
@@ -287,7 +288,7 @@ open class ShortNotePostViewModel :
|
||||
|
||||
// Voice Messages
|
||||
var voiceRecording by mutableStateOf<RecordingResult?>(null)
|
||||
var voiceLocalFile by mutableStateOf<java.io.File?>(null)
|
||||
var voiceLocalFile by mutableStateOf<Path?>(null)
|
||||
var isUploadingVoice by mutableStateOf(false)
|
||||
var voiceMetadata by mutableStateOf<AudioMeta?>(null)
|
||||
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
|
||||
@@ -305,9 +306,10 @@ open class ShortNotePostViewModel :
|
||||
error,
|
||||
)
|
||||
},
|
||||
anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) },
|
||||
)
|
||||
|
||||
val activeFile: java.io.File?
|
||||
val activeFile: Path?
|
||||
get() = voiceAnonymization.activeFile(voiceLocalFile)
|
||||
|
||||
val activeWaveform: List<Float>?
|
||||
@@ -478,9 +480,9 @@ open class ShortNotePostViewModel :
|
||||
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
|
||||
BooleanType.ALWAYS
|
||||
|
||||
fun initWritingAssistant(context: Context) {
|
||||
fun initWritingAssistant() {
|
||||
if (writingAssistant != null) return
|
||||
writingAssistant = WritingAssistantFactory.create(context)
|
||||
writingAssistant = accountViewModel.host.createWritingAssistant() ?: return
|
||||
refreshAiStatus()
|
||||
}
|
||||
|
||||
@@ -1260,21 +1262,23 @@ open class ShortNotePostViewModel :
|
||||
* Runs on the mining queue's scope when PoW is on, so it must not touch
|
||||
* viewModelScope.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private suspend fun storeScheduledPost(
|
||||
template: EventTemplate<out Event>,
|
||||
extraNotesToBroadcast: List<Event>,
|
||||
publishAtSec: Long,
|
||||
) {
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||
Amethyst.instance.scheduledPostStore.add(
|
||||
val store = accountViewModel.host.scheduledPostStore ?: return
|
||||
store.add(
|
||||
ScheduledPost(
|
||||
id = UUID.randomUUID().toString(),
|
||||
id = Uuid.random().toString(),
|
||||
accountPubkey = event.pubKey,
|
||||
signedEventJson = event.toJson(),
|
||||
relayUrls = relays.map { it.url },
|
||||
extraEventsJson = extras.map { it.toJson() },
|
||||
publishAtSec = publishAtSec,
|
||||
createdAtSec = System.currentTimeMillis() / 1000,
|
||||
createdAtSec = TimeUtils.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1570,7 +1574,7 @@ open class ShortNotePostViewModel :
|
||||
val replyingToEvent = originalNote?.event ?: return false
|
||||
return replyingToEvent is TextNoteEvent &&
|
||||
replyingToEvent.isNewThread() &&
|
||||
replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME)
|
||||
replyingToEvent.isClient(AMETHYST_CLIENT_TAG_NAME)
|
||||
}
|
||||
|
||||
fun upload(
|
||||
@@ -1916,14 +1920,13 @@ open class ShortNotePostViewModel :
|
||||
voiceOrchestrator = null
|
||||
}
|
||||
|
||||
private fun deleteVoiceLocalFile(toDelete: java.io.File? = voiceLocalFile) {
|
||||
private fun deleteVoiceLocalFile(toDelete: Path? = voiceLocalFile) {
|
||||
toDelete?.let { file ->
|
||||
try {
|
||||
if (file.delete()) {
|
||||
Log.d("ShortNotePostViewModel") { "Deleted voice file: ${file.absolutePath}" }
|
||||
}
|
||||
platformFileSystem.delete(file, mustExist = false)
|
||||
Log.d("ShortNotePostViewModel") { "Deleted voice file: $file" }
|
||||
} catch (e: Exception) {
|
||||
Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e)
|
||||
Log.w("ShortNotePostViewModel", "Failed to delete voice file: $file", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1942,7 +1945,7 @@ open class ShortNotePostViewModel :
|
||||
isUploadingVoice = true
|
||||
|
||||
try {
|
||||
val uri = android.net.Uri.fromFile(fileToUpload)
|
||||
val uri = mediaUriOfFile(fileToUpload)
|
||||
val orchestrator = UploadOrchestrator()
|
||||
voiceOrchestrator = orchestrator
|
||||
|
||||
|
||||
+14
-12
@@ -27,6 +27,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
@@ -41,11 +43,10 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.mediaUriOfFile
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
|
||||
import com.vitorpamplona.amethyst.commons.util.platformFileSystem
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.markedETags
|
||||
@@ -61,7 +62,8 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
@Stable
|
||||
class VoiceReplyViewModel : ViewModel() {
|
||||
@@ -70,7 +72,7 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
var replyToNote: Note? by mutableStateOf(null)
|
||||
|
||||
var voiceRecording: RecordingResult? by mutableStateOf(null)
|
||||
var voiceLocalFile: File? by mutableStateOf(null)
|
||||
var voiceLocalFile: Path? by mutableStateOf(null)
|
||||
var voiceMetadata: AudioMeta? by mutableStateOf(null)
|
||||
var voiceSelectedServer: ServerName? by mutableStateOf(null)
|
||||
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
|
||||
@@ -89,9 +91,10 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
},
|
||||
anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) },
|
||||
)
|
||||
|
||||
val activeFile: File?
|
||||
val activeFile: Path?
|
||||
get() = voiceAnonymization.activeFile(voiceLocalFile)
|
||||
|
||||
val activeWaveform: List<Float>?
|
||||
@@ -126,7 +129,7 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val file = File(recordingFilePath)
|
||||
val file = recordingFilePath.toPath()
|
||||
voiceLocalFile = file
|
||||
voiceRecording =
|
||||
RecordingResult(
|
||||
@@ -164,11 +167,10 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
private fun deleteVoiceLocalFile() {
|
||||
voiceLocalFile?.let { file ->
|
||||
try {
|
||||
if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) {
|
||||
Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: ${file.absolutePath}" }
|
||||
}
|
||||
platformFileSystem.delete(file, mustExist = false)
|
||||
Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: $file" }
|
||||
} catch (e: Exception) {
|
||||
Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e)
|
||||
Log.w("VoiceReplyViewModel", "Failed to delete voice file: $file", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +197,7 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
try {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = android.net.Uri.fromFile(fileToUpload)
|
||||
val uri = mediaUriOfFile(fileToUpload)
|
||||
orchestrator.upload(
|
||||
uri = uri,
|
||||
mimeType = recording.mimeType,
|
||||
|
||||
@@ -87,8 +87,9 @@ kotlin {
|
||||
// the ForwardingFileSystem the apps wrap Coil's disk cache in. It
|
||||
// used to arrive transitively through Coil; with Coil in
|
||||
// :commonsUI the Apple targets lost it (JVM still saw it via
|
||||
// OkHttp), so declare the dependency the file actually has.
|
||||
implementation(libs.okio)
|
||||
// OkHttp), so declare the dependency the file actually has. `api`
|
||||
// because voice recordings cross module boundaries as okio Paths.
|
||||
api(libs.okio)
|
||||
|
||||
// DataStore (KMP, Apache-2.0) — the preference storage layer.
|
||||
// Publishes android/jvm/ios/linux/macos variants plus common
|
||||
|
||||
+3
@@ -21,7 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.commons.service.uploads
|
||||
|
||||
import android.net.Uri
|
||||
import okio.Path
|
||||
|
||||
actual typealias MediaUri = Uri
|
||||
|
||||
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegment
|
||||
|
||||
actual fun mediaUriOfFile(path: Path): MediaUri = Uri.fromFile(path.toFile())
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.audio
|
||||
|
||||
import okio.Path
|
||||
|
||||
/** A finished voice-message recording: the audio file, its type, and its waveform. */
|
||||
class RecordingResult(
|
||||
val file: Path,
|
||||
val mimeType: String,
|
||||
val amplitudes: List<Float>,
|
||||
val duration: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* A recording re-voiced for privacy.
|
||||
*
|
||||
* @property file The output audio file (AAC in MP4 container)
|
||||
* @property waveform Amplitude data for waveform visualization (one value per second)
|
||||
* @property duration Audio duration in seconds
|
||||
*/
|
||||
data class AnonymizedResult(
|
||||
val file: Path,
|
||||
val waveform: List<Float>,
|
||||
val duration: Int,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model
|
||||
|
||||
/** The name this app signs into NIP-89 `client` tags, and recognizes on events it wrote. */
|
||||
const val AMETHYST_CLIENT_TAG_NAME = "Amethyst"
|
||||
+5
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.uploads
|
||||
|
||||
import okio.Path
|
||||
|
||||
/**
|
||||
* A picked file's address as the platform hands it over: Android's `content://` [android.net.Uri]
|
||||
* (a typealias there, so app code keeps reading it as a `Uri`), a string-backed subclass
|
||||
@@ -30,6 +32,9 @@ expect abstract class MediaUri {
|
||||
abstract override fun toString(): String
|
||||
}
|
||||
|
||||
/** The address of a local file, such as a voice recording the app wrote itself. */
|
||||
expect fun mediaUriOfFile(path: Path): MediaUri
|
||||
|
||||
/** The last path segment of this address, if it has one (on Android, `Uri.lastPathSegment`). */
|
||||
expect fun MediaUri.lastPathSegmentOrNull(): String?
|
||||
|
||||
|
||||
+19
@@ -20,7 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult
|
||||
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
|
||||
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
|
||||
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
|
||||
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
|
||||
@@ -34,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okio.Path
|
||||
|
||||
/**
|
||||
* What the account's ViewModel needs from the process it runs in: app-wide services that outlive
|
||||
@@ -74,6 +78,21 @@ interface AccountViewModelHost {
|
||||
/** The device's position as geohashes. */
|
||||
val deviceLocation: DeviceLocation get() = DeviceLocation.None
|
||||
|
||||
/** Posts signed now and published later by a background worker; null where scheduling is unsupported. */
|
||||
val scheduledPostStore: ScheduledPostStore? get() = null
|
||||
|
||||
/**
|
||||
* Re-voices the recording at [input] with the voice preset named [presetName], into a new file
|
||||
* beside it.
|
||||
*/
|
||||
suspend fun anonymizeVoice(
|
||||
input: Path,
|
||||
presetName: String,
|
||||
): Result<AnonymizedResult> = Result.failure(UnsupportedOperationException("Voice anonymization is not available on this platform"))
|
||||
|
||||
/** A new on-device writing assistant, or null where the platform has none. */
|
||||
fun createWritingAssistant(): WritingAssistant? = null
|
||||
|
||||
/** Public keys of every account saved on this device. */
|
||||
val savedAccounts: Flow<Set<HexKey>>
|
||||
|
||||
|
||||
+4
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.uploads
|
||||
|
||||
import okio.Path
|
||||
|
||||
actual abstract class MediaUri {
|
||||
actual abstract override fun toString(): String
|
||||
}
|
||||
@@ -38,3 +40,5 @@ class StringMediaUri(
|
||||
}
|
||||
|
||||
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString())
|
||||
|
||||
actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString())
|
||||
|
||||
+4
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.uploads
|
||||
|
||||
import okio.Path
|
||||
|
||||
actual abstract class MediaUri {
|
||||
actual abstract override fun toString(): String
|
||||
}
|
||||
@@ -38,3 +40,5 @@ class StringMediaUri(
|
||||
}
|
||||
|
||||
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString())
|
||||
|
||||
actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString())
|
||||
|
||||
+11
-15
@@ -18,22 +18,24 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
package com.vitorpamplona.amethyst.commons.audio
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
|
||||
import com.vitorpamplona.amethyst.commons.util.platformFileSystem
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import okio.Path
|
||||
|
||||
class VoiceAnonymizationController(
|
||||
private val scope: CoroutineScope,
|
||||
private val logTag: String,
|
||||
private val onError: (Throwable) -> Unit,
|
||||
/** Re-voices a recording with a preset; the platform's audio pipeline, reached through the host. */
|
||||
private val anonymize: suspend (Path, VoicePreset) -> Result<AnonymizedResult>,
|
||||
) {
|
||||
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE)
|
||||
private set
|
||||
@@ -44,7 +46,7 @@ class VoiceAnonymizationController(
|
||||
|
||||
private var processingJob: Job? = null
|
||||
|
||||
fun activeFile(originalFile: File?): File? =
|
||||
fun activeFile(originalFile: Path?): Path? =
|
||||
if (selectedPreset == VoicePreset.NONE) {
|
||||
originalFile
|
||||
} else {
|
||||
@@ -60,7 +62,7 @@ class VoiceAnonymizationController(
|
||||
|
||||
fun selectPreset(
|
||||
preset: VoicePreset,
|
||||
originalFile: File?,
|
||||
originalFile: Path?,
|
||||
) {
|
||||
Log.d(logTag) { "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}" }
|
||||
if (processingPreset != null || preset == selectedPreset) return
|
||||
@@ -82,8 +84,7 @@ class VoiceAnonymizationController(
|
||||
processingJob =
|
||||
scope.launch {
|
||||
try {
|
||||
val anonymizer = VoiceAnonymizer()
|
||||
val result = anonymizer.anonymize(file, preset)
|
||||
val result = anonymize(file, preset)
|
||||
|
||||
result
|
||||
.onSuccess { anonymizedResult ->
|
||||
@@ -109,15 +110,10 @@ class VoiceAnonymizationController(
|
||||
fun deleteDistortedFiles() {
|
||||
distortedFiles.values.forEach { result ->
|
||||
try {
|
||||
if (result.file.exists()) {
|
||||
if (result.file.delete()) {
|
||||
Log.d(logTag) { "Deleted distorted file: ${result.file.absolutePath}" }
|
||||
} else {
|
||||
Log.w(logTag) { "Failed to delete distorted file: ${result.file.absolutePath}" }
|
||||
}
|
||||
}
|
||||
platformFileSystem.delete(result.file, mustExist = false)
|
||||
Log.d(logTag) { "Deleted distorted file: ${result.file}" }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file}", e)
|
||||
}
|
||||
}
|
||||
distortedFiles = emptyMap()
|
||||
Reference in New Issue
Block a user