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:
Claude
2026-09-30 01:54:12 +00:00
parent 51923a2224
commit e1e64233c9
22 changed files with 198 additions and 91 deletions
@@ -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
@@ -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
@@ -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).
@@ -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)
}
@@ -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),
@@ -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()
@@ -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
@@ -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() }
}
}
@@ -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()
}
}
@@ -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
@@ -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,
+3 -2
View File
@@ -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
@@ -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())
@@ -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"
@@ -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?
@@ -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>>
@@ -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())
@@ -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())
@@ -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()