Share full projects as .szgz bundles between instances (v0.16.0)
Add Export/Import project buttons to Settings → Project. A bundle is a ZIP holding the full .szg project plus every sample WAV it references (Sampler pads, SoundFont), so unlike a plain .szg — whose sample ids only resolve on the originating device — it opens complete on another Sizzletracker instance. Export shares the bundle via the system share sheet. Import replaces the current project, extracts the WAVs to a persistent content-addressed samples dir, re-points each slot's padNFile/sfFile at the extracted file, then rehydrates — so the audio is restored for the session and survives a restart. Invalid files are rejected cleanly. Covered by ProjectBundleIoTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 40
|
versionCode = 41
|
||||||
versionName = "0.15.1"
|
versionName = "0.16.0"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.io
|
||||||
|
|
||||||
|
import space.rcmd.android.sizzle.audio.SampleStore
|
||||||
|
import space.rcmd.android.sizzle.model.Project
|
||||||
|
import space.rcmd.android.sizzle.model.SamplerPads
|
||||||
|
import space.rcmd.android.sizzle.model.ToolboxType
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A portable, self-contained project bundle for sharing whole projects between
|
||||||
|
* Sizzletracker instances: a ZIP holding the full `.szg` project plus every sample
|
||||||
|
* WAV it references (Sampler pads and SoundFont slots). Unlike a plain `.szg` — whose
|
||||||
|
* sample ids only resolve against the originating device's stores — a bundle carries
|
||||||
|
* the audio, so importing on another device rebuilds the project *and* its sounds.
|
||||||
|
*/
|
||||||
|
object ProjectBundleIo {
|
||||||
|
private const val PROJECT_ENTRY = "project.szg"
|
||||||
|
private const val MANIFEST = "samples/manifest.txt"
|
||||||
|
private const val SAMPLE_DIR = "samples/"
|
||||||
|
|
||||||
|
/** Write [project] and all its referenced samples to [out] as a ZIP bundle. */
|
||||||
|
fun write(project: Project, out: OutputStream) {
|
||||||
|
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
|
||||||
|
zip.putNextEntry(ZipEntry(PROJECT_ENTRY))
|
||||||
|
zip.write(ProjectIo.save(project).toByteArray(Charsets.UTF_8))
|
||||||
|
zip.closeEntry()
|
||||||
|
|
||||||
|
val manifest = StringBuilder()
|
||||||
|
collectSampleIds(project).forEachIndexed { i, id ->
|
||||||
|
val sample = SampleStore.get(id) ?: return@forEachIndexed
|
||||||
|
zip.putNextEntry(ZipEntry("$SAMPLE_DIR$i.wav"))
|
||||||
|
zip.write(SampleStore.encodeWav(sample))
|
||||||
|
zip.closeEntry()
|
||||||
|
manifest.append(i).append('\t').append(id).append('\n')
|
||||||
|
}
|
||||||
|
zip.putNextEntry(ZipEntry(MANIFEST))
|
||||||
|
zip.write(manifest.toString().toByteArray(Charsets.UTF_8))
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a project bundle from [input] into [project] (replacing it), extracting its
|
||||||
|
* sample WAVs into [samplesDir] and pointing each sample-referencing slot at the
|
||||||
|
* on-disk copy (so the caller's `rehydrate` — and every later startup — resolves
|
||||||
|
* the audio). Returns false, leaving the project untouched, if the ZIP holds no
|
||||||
|
* project. The caller should rehydrate + rebuild FX routing afterwards, as for a
|
||||||
|
* normal load.
|
||||||
|
*/
|
||||||
|
fun read(project: Project, input: InputStream, samplesDir: File): Boolean {
|
||||||
|
var projectText: String? = null
|
||||||
|
val manifest = HashMap<String, String>() // sample index -> original id
|
||||||
|
val wavByIndex = HashMap<String, ByteArray>() // sample index -> WAV bytes
|
||||||
|
try {
|
||||||
|
ZipInputStream(BufferedInputStream(input)).use { zin ->
|
||||||
|
var entry: ZipEntry? = zin.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name
|
||||||
|
if (!entry.isDirectory && !name.contains("..")) {
|
||||||
|
val bytes = zin.readBytes()
|
||||||
|
when {
|
||||||
|
name == PROJECT_ENTRY -> projectText = String(bytes, Charsets.UTF_8)
|
||||||
|
name == MANIFEST -> String(bytes, Charsets.UTF_8).lineSequence().forEach { ln ->
|
||||||
|
val tab = ln.indexOf('\t')
|
||||||
|
if (tab > 0) manifest[ln.substring(0, tab)] = ln.substring(tab + 1)
|
||||||
|
}
|
||||||
|
name.startsWith(SAMPLE_DIR) && name.endsWith(".wav") ->
|
||||||
|
wavByIndex[name.removePrefix(SAMPLE_DIR).removeSuffix(".wav")] = bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zin.closeEntry()
|
||||||
|
entry = zin.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val text = projectText ?: return false
|
||||||
|
|
||||||
|
// Persist each sample and map its original id to the extracted file. Content-
|
||||||
|
// addressed names dedup re-imports and never collide.
|
||||||
|
samplesDir.mkdirs()
|
||||||
|
val idToPath = HashMap<String, String>()
|
||||||
|
for ((idx, originalId) in manifest) {
|
||||||
|
val bytes = wavByIndex[idx] ?: continue
|
||||||
|
val f = File(samplesDir, "s" + Integer.toHexString(bytes.contentHashCode()) + ".wav")
|
||||||
|
if (!f.exists()) f.writeBytes(bytes)
|
||||||
|
idToPath[originalId] = f.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectIo.loadInto(project, text)
|
||||||
|
|
||||||
|
// Re-point sample-referencing slots at the extracted WAVs (absolute paths), so
|
||||||
|
// rehydrate resolves them here and after any future restart.
|
||||||
|
project.toolbox.forEach { slot ->
|
||||||
|
when (slot.type) {
|
||||||
|
ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) {
|
||||||
|
idToPath[slot.string(SamplerPads.key(p))]?.let { slot.set("pad${p}File", it) }
|
||||||
|
}
|
||||||
|
ToolboxType.SOUNDFONT -> idToPath[slot.string("sfSample")]?.let { slot.set("sfFile", it) }
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every non-blank sample id referenced by the project's Sampler / SoundFont slots. */
|
||||||
|
private fun collectSampleIds(project: Project): List<String> {
|
||||||
|
val ids = LinkedHashSet<String>()
|
||||||
|
project.toolbox.forEach { slot ->
|
||||||
|
when (slot.type) {
|
||||||
|
ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) {
|
||||||
|
slot.string(SamplerPads.key(p)).takeIf { it.isNotBlank() }?.let { ids.add(it) }
|
||||||
|
}
|
||||||
|
ToolboxType.SOUNDFONT -> slot.string("sfSample").takeIf { it.isNotBlank() }?.let { ids.add(it) }
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,12 +54,20 @@ class SongLibrary(private val baseDir: File) {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A shareable file path in the export sub-dir for a full project bundle ([BUNDLE_EXT]);
|
||||||
|
* the caller writes the ZIP into it via [ProjectBundleIo]. */
|
||||||
|
fun bundleExportFile(name: String): File {
|
||||||
|
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
|
||||||
|
return File(dir, sanitize(name) + BUNDLE_EXT)
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep names safe as file names while staying human-readable. */
|
/** Keep names safe as file names while staying human-readable. */
|
||||||
private fun sanitize(name: String): String =
|
private fun sanitize(name: String): String =
|
||||||
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
|
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val EXT = ".szg" // full project (ProjectIo)
|
const val EXT = ".szg" // full project (ProjectIo)
|
||||||
|
const val BUNDLE_EXT = ".szgz" // full project + samples ZIP bundle (ProjectBundleIo)
|
||||||
const val EXPORT_DIR = ".export"
|
const val EXPORT_DIR = ".export"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -949,6 +949,35 @@ class AppViewModel(
|
|||||||
fun writeSngExport(): java.io.File =
|
fun writeSngExport(): java.io.File =
|
||||||
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
|
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
|
||||||
|
|
||||||
|
/** Write the current project as a full, self-contained `.szgz` bundle (project +
|
||||||
|
* all referenced sample WAVs) to a shareable file, for moving whole projects
|
||||||
|
* between Sizzletracker instances. */
|
||||||
|
fun writeProjectBundle(): java.io.File {
|
||||||
|
val f = songLibrary.bundleExportFile(project.name.ifBlank { "project" })
|
||||||
|
f.outputStream().use { space.rcmd.android.sizzle.io.ProjectBundleIo.write(project, it) }
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a full project bundle (in place), extracting its samples and restoring
|
||||||
|
* the audio. Returns false (leaving the current project) if it isn't a valid
|
||||||
|
* bundle. Replaces the current project, like [loadSong]. */
|
||||||
|
fun importProjectBundle(bytes: ByteArray): Boolean {
|
||||||
|
engine.stop()
|
||||||
|
val samplesDir = java.io.File(appContext.filesDir, "projectsamples")
|
||||||
|
val ok = runCatching {
|
||||||
|
space.rcmd.android.sizzle.io.ProjectBundleIo.read(
|
||||||
|
project, java.io.ByteArrayInputStream(bytes), samplesDir,
|
||||||
|
)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (ok) {
|
||||||
|
presetLibrary.rehydrate(project) // decode the extracted WAVs into the SampleStore
|
||||||
|
engine.rebuildFxChains() // mixer FX routing may have changed
|
||||||
|
cursorLine = 0; cursorTrack = 0
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- full backup/restore
|
// ------------------------------------------------------------- full backup/restore
|
||||||
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
|
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
|
||||||
var backupMessage by mutableStateOf<String?>(null); private set
|
var backupMessage by mutableStateOf<String?>(null); private set
|
||||||
|
|||||||
@@ -236,7 +236,21 @@ private fun ProjectCard(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
|
// Import a full project bundle (.szgz: project + its sample WAVs) from another
|
||||||
|
// Sizzletracker instance, replacing the current project.
|
||||||
|
val bundleImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
val bytes = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull()
|
||||||
|
when {
|
||||||
|
bytes == null -> importError = "Couldn't read the selected file."
|
||||||
|
!vm.importProjectBundle(bytes) -> importError =
|
||||||
|
"Couldn't import this file. It doesn't look like a Sizzletracker project bundle."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard("Project", subtitle = "Full projects saved on this device; share bundles between instances") {
|
||||||
// On-device full-project browser: the load dropdown sits ABOVE the action
|
// On-device full-project browser: the load dropdown sits ABOVE the action
|
||||||
// buttons (save / new / delete) so the selector is clear of them.
|
// buttons (save / new / delete) so the selector is clear of them.
|
||||||
Box {
|
Box {
|
||||||
@@ -271,10 +285,29 @@ private fun ProjectCard(vm: AppViewModel) {
|
|||||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
// Full project bundle (.szgz): project + all sample WAVs, for sharing whole
|
||||||
|
// projects between Sizzletracker instances.
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { shareFile(context, vm.writeProjectBundle(), "application/zip", "Share project") }) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||||
|
Text("Export project")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { bundleImporter.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
|
||||||
|
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||||
|
Text("Import project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"A bundle carries everything — patterns, mixer, FX, instruments and samples — " +
|
||||||
|
"so it opens complete on another device. Import replaces the current project.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
// Desktop interchange: .sng is only exported / imported, never the library.
|
// Desktop interchange: .sng is only exported / imported, never the library.
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
|
OutlinedButton(onClick = { shareFile(context, vm.writeSngExport(), "text/plain", "Share song") }) {
|
||||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||||
Text("Export .sng")
|
Text("Export .sng")
|
||||||
}
|
}
|
||||||
@@ -348,15 +381,15 @@ private fun ProjectCard(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Launch the system share sheet for an exported .sng file. */
|
/** Launch the system share sheet for an exported file (song, .sng, or bundle). */
|
||||||
private fun shareSongFile(context: Context, file: java.io.File) {
|
private fun shareFile(context: Context, file: java.io.File, mime: String, title: String) {
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = mime
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
context.startActivity(Intent.createChooser(send, "Share song"))
|
context.startActivity(Intent.createChooser(send, title))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.io
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import space.rcmd.android.sizzle.audio.SampleStore
|
||||||
|
import space.rcmd.android.sizzle.model.Project
|
||||||
|
import space.rcmd.android.sizzle.model.SamplerPads
|
||||||
|
import space.rcmd.android.sizzle.model.ToolboxType
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
/** A project bundle must carry the referenced sample audio and rebuild it on import. */
|
||||||
|
class ProjectBundleIoTest {
|
||||||
|
|
||||||
|
@Test fun roundTripCarriesTheSampleAndRepointsTheSlot() {
|
||||||
|
// A source project with a Sampler pad referencing a sample in the store.
|
||||||
|
val id = "src-sample-id"
|
||||||
|
SampleStore.put(id, SampleStore.Sample(FloatArray(256) { it / 256f - 0.5f }, 48_000))
|
||||||
|
val src = Project().apply {
|
||||||
|
name = "Shared Tune"
|
||||||
|
toolbox[3].fill(ToolboxType.SAMPLER)
|
||||||
|
toolbox[3].set(SamplerPads.key(0), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = ByteArrayOutputStream().also { ProjectBundleIo.write(src, it) }.toByteArray()
|
||||||
|
|
||||||
|
// Import into a fresh project + samples dir (simulating another device).
|
||||||
|
val samplesDir = Files.createTempDirectory("bundle-samples").toFile()
|
||||||
|
val dst = Project()
|
||||||
|
assertTrue(ProjectBundleIo.read(dst, ByteArrayInputStream(bytes), samplesDir))
|
||||||
|
|
||||||
|
assertEquals("Shared Tune", dst.name)
|
||||||
|
assertEquals(ToolboxType.SAMPLER, dst.toolbox[3].type)
|
||||||
|
// The pad still references the original id (resolved by rehydrate at load time)…
|
||||||
|
assertEquals(id, dst.toolbox[3].string(SamplerPads.key(0)))
|
||||||
|
// …and now carries a portable file ref to the extracted WAV, which exists and decodes.
|
||||||
|
val wavPath = dst.toolbox[3].string("pad0File")
|
||||||
|
assertTrue("pad0File points at an extracted WAV", wavPath.isNotBlank())
|
||||||
|
val wav = File(wavPath)
|
||||||
|
assertTrue("extracted WAV exists on disk", wav.exists())
|
||||||
|
val decoded = SampleStore.decodeWav(wav.readBytes())
|
||||||
|
assertNotNull("the carried sample decodes", decoded)
|
||||||
|
assertTrue(decoded!!.data.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invalidBundleIsRejected() {
|
||||||
|
val dst = Project()
|
||||||
|
val samplesDir = Files.createTempDirectory("bundle-samples2").toFile()
|
||||||
|
assertFalse(ProjectBundleIo.read(dst, ByteArrayInputStream("not a zip".toByteArray()), samplesDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user