From a2ea57e23c57c1f66fbba1ee464f9d254c8b9017 Mon Sep 17 00:00:00 2001 From: Nowko Date: Sun, 11 Oct 2026 20:46:51 +0200 Subject: [PATCH] perf: share decoded note names and skip redundant project parses Decoded notes now share one copy of each instrument and sample name, cutting a 7,500-note project from about 1.1 MB to 0.2 MB of heap. Saves check the legacy single-project layout once per owner instead of parsing the selected file on every save and checkpoint. The sample-name pattern is compiled once, and publishing checks audibility without building a full timeline. Co-Authored-By: Claude Opus 5.5 --- .../musicalinstruments/studio/Note.java | 6 +++- .../musicalinstruments/studio/Song.java | 6 ++++ .../studio/StudioService.java | 2 +- .../studio/StudioStore.java | 21 ++++++++++++-- .../studio/StudioModelTest.java | 10 +++++++ .../studio/StudioStoreRecoveryTest.java | 5 ++-- .../studio/StudioStoreTest.java | 29 +++++++++++++++++-- 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/Note.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/Note.java index 02a6b5b..1783f08 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/Note.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/Note.java @@ -1,10 +1,14 @@ package net.tfminecraft.musicalinstruments.studio; +import java.util.regex.Pattern; + /** A resolved sample trigger, captured independently of subsequent config changes. */ public record Note(int tick, String instrument, String sound, float volume, float pitch) { + private static final Pattern SOUND = Pattern.compile("[a-z0-9_.-]+(:[a-z0-9_./-]+)?"); + public Note { if (tick < 0 || tick >= Song.MAX_TICKS || instrument == null || instrument.length() > 100 - || sound == null || sound.length() > 200 || !sound.matches("[a-z0-9_.-]+(:[a-z0-9_./-]+)?") + || sound == null || sound.length() > 200 || !SOUND.matcher(sound).matches() || !Float.isFinite(volume) || volume < 0 || volume > 16 || !Float.isFinite(pitch) || pitch < 0.5f || pitch > 2) { throw new IllegalArgumentException("Invalid recorded note"); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java index 71e1477..099b10d 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java @@ -41,6 +41,12 @@ public Track track(int slot) { return tracks.stream().filter(track -> track.slot() == slot).findFirst().orElse(null); } + /** Whether playback emits any sound; matches the notes {@link Timeline} schedules. */ + public boolean audible() { + return tracks.stream().anyMatch(track -> !track.muted() && track.gain() > 0 + && track.notes().stream().anyMatch(note -> note.volume() > 0)); + } + public Song replace(Track track) { List result = new ArrayList<>(tracks); result.removeIf(existing -> existing.slot() == track.slot()); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java index 1a91eda..16b9f7f 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java @@ -357,7 +357,7 @@ private Song publishEdition(Player player) throws IOException { if (project.pending() != null) { throw new IllegalArgumentException("Keep or discard your pending take first"); } - if (new Timeline(project.song(), -1).due(Song.MAX_TICKS).isEmpty()) { + if (!project.song().audible()) { throw new IllegalArgumentException("The mix needs at least one audible note"); } Song edition = project.song().edition(); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java index 32ca14e..7d0c457 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java @@ -15,13 +15,18 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; /** Versioned, bounded data files. A failed write never truncates the last saved project. */ public final class StudioStore { // Enough for a full 12,000-note song plus a replacement take, including escaped names. static final int MAX_FILE_BYTES = 32_000_000; private final Path root; + // Owners whose selected file already has its per-project copy. Both the main thread + // and the checkpoint writer save, so the set is concurrent. + private final Set archived = ConcurrentHashMap.newKeySet(); public StudioStore(Path root) throws IOException { this.root = root; @@ -115,8 +120,9 @@ public void save(Project project) throws IOException { UUID owner = project.song().owner(); Path selectedFile = root.resolve("projects").resolve(owner + ".yml"); Files.createDirectories(projectFile(owner, project.song().id()).getParent()); - // Archive the original single-project file before selecting a different song. - if (Files.exists(selectedFile)) { + // Archive the original single-project file before selecting a different song. Every save + // below writes both copies, so this parse runs once per owner instead of on every save. + if (!archived.contains(owner) && Files.exists(selectedFile)) { Project previous = decodeProject(selectedFile, owner, null); Path previousFile = projectFile(owner, previous.song().id()); if (!Files.exists(previousFile)) write(previousFile, encodeProject(previous), true); @@ -124,6 +130,7 @@ public void save(Project project) throws IOException { YamlConfiguration yaml = encodeProject(project); write(projectFile(owner, project.song().id()), yaml, true); write(selectedFile, yaml, true); + archived.add(owner); } private YamlConfiguration encodeProject(Project project) { @@ -228,7 +235,7 @@ private Track decodeTrack(Map raw) { if (note.size() != 5) { throw new IllegalArgumentException("Invalid note fields"); } - notes.add(new Note(integer(note.get(0)), (String) note.get(1), (String) note.get(2), + notes.add(new Note(integer(note.get(0)), text(note.get(1)), text(note.get(2)), number(note.get(3)).floatValue(), number(note.get(4)).floatValue())); } return new Track(integer(raw.get("slot")), integer(raw.get("length")), @@ -247,6 +254,14 @@ private List list(Object value) { return (List) value; } + private String text(Object value) { + if (!(value instanceof String text)) { + throw new IllegalArgumentException("Expected text"); + } + // Songs repeat a few instrument and sample names; share one copy instead of one per note. + return text.intern(); + } + private Number number(Object value) { if (!(value instanceof Number number)) { throw new IllegalArgumentException("Expected a number"); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java index ba098fe..3ed96b0 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java @@ -40,6 +40,16 @@ void backingExcludesTheTrackBeingReplacedAndMutedTracks() { assertEquals(List.of("backing"), new Timeline(song, 1).due(0).stream().map(t -> t.note().sound()).toList()); } + @Test + void audibilityIgnoresMutedSilentAndZeroVolumeTracks() { + Track muted = new Track(1, 10, 1, false, List.of(note(0, "a"))).mix(1, true); + Track silent = new Track(2, 10, 0, false, List.of(note(0, "b"))); + Track quiet = new Track(3, 10, 1, false, List.of(new Note(0, "lute", "c", 0, 1))); + assertFalse(song(muted, silent, quiet).audible()); + assertFalse(song().audible()); + assertTrue(song(muted, new Track(4, 10, 1, false, List.of(new Note(0, "lute", "d", 0, 1), note(1, "e")))).audible()); + } + @Test void acceptingOrDiscardingATakePreservesPublishedEditions() { Song original = song(new Track(1, 100, 1, false, List.of(note(0, "original")))); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java index b7cc098..3f64643 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java @@ -102,12 +102,13 @@ void malformedNumericFieldsAndOversizedSongStructuresCannotLoad() throws Excepti track.put("slot", slot); yaml.set("tracks", List.of(track)); yaml.save(file.toFile()); assertThrows(IOException.class, () -> store.song(project.song().id())); } - for (int mode = 0; mode < 3; mode++) { + for (int mode = 0; mode < 4; mode++) { YamlConfiguration yaml = new YamlConfiguration(); yaml.loadFromString(valid); Map track = new LinkedHashMap<>((Map) yaml.getMapList("tracks").getFirst()); if (mode == 0) yaml.set("tracks", Collections.nCopies(9, track)); else { - track.put("notes", mode == 1 ? Collections.nCopies(Song.MAX_NOTES + 1, List.of(0, "lute", "note", 1, 1)) : List.of(List.of(0, "lute"))); + track.put("notes", mode == 1 ? Collections.nCopies(Song.MAX_NOTES + 1, List.of(0, "lute", "note", 1, 1)) + : mode == 2 ? List.of(List.of(0, "lute")) : List.of(List.of(0, "lute", 7, 1, 1))); yaml.set("tracks", List.of(track)); } yaml.save(file.toFile()); assertThrows(IOException.class, () -> store.song(project.song().id())); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java index f8b96f1..1aacdbc 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java @@ -103,9 +103,9 @@ void switchingProjectsMigratesTheLegacySongWithoutLosingItsPendingTake() throws Project legacy = project(); store.save(legacy); Path archived = directory.resolve("projects").resolve(legacy.song().owner().toString()).resolve(legacy.song().id() + ".yml"); - Files.delete(archived); // Simulate the original single-project layout. + Files.delete(archived); // Simulate the original single-project layout, present when the server starts. Project second = new Project(new Song(UUID.randomUUID(), legacy.song().owner(), "Musician", "Second", List.of()), null, 100, true); - store.save(second); + new StudioStore(directory).save(second); StudioStore reopened = new StudioStore(directory); assertEquals(2, reopened.projects(legacy.song().owner()).size()); assertEquals(legacy, reopened.project(legacy.song().owner(), legacy.song().id())); @@ -115,6 +115,31 @@ void switchingProjectsMigratesTheLegacySongWithoutLosingItsPendingTake() throws assertEquals(second, reopened.project(legacy.song().owner(), second.song().id())); } + @Test + void decodedNotesShareRepeatedInstrumentAndSampleNames() throws Exception { + var notes = List.of(new Note(0, "lute", "tfmc:lute.c4", 1, 1), new Note(1, "lute", "tfmc:lute.c4", 1, 1)); + Project project = new Project(new Song(UUID.randomUUID(), UUID.randomUUID(), "Musician", "Song", + List.of(new Track(1, 10, 1, false, notes))), null, 100, false); + new StudioStore(directory).save(project); + Song song = new StudioStore(directory).project(project.song().owner()).orElseThrow().song(); + Note first = song.track(1).notes().get(0); + Note second = song.track(1).notes().get(1); + assertSame(first.instrument(), second.instrument()); + assertSame(first.sound(), second.sound()); + } + + @Test + void laterSavesDoNotReparseTheSelectedFile() throws Exception { + StudioStore store = new StudioStore(directory); + Project project = project(); + store.save(project); + Path selected = directory.resolve("projects").resolve(project.song().owner() + ".yml"); + Files.writeString(selected, "[broken yaml"); + assertThrows(IOException.class, () -> new StudioStore(directory).save(project)); // A new store checks once. + store.save(project.accept()); + assertEquals(project.accept(), store.project(project.song().owner()).orElseThrow()); + } + @Test void projectLibrariesAndDirectSelectionCannotCrossOwners() throws Exception { StudioStore store = new StudioStore(directory);