Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Track> result = new ArrayList<>(tracks);
result.removeIf(existing -> existing.slot() == track.slot());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> archived = ConcurrentHashMap.newKeySet();

public StudioStore(Path root) throws IOException {
this.root = root;
Expand Down Expand Up @@ -115,15 +120,17 @@ 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);
}
YamlConfiguration yaml = encodeProject(project);
write(projectFile(owner, project.song().id()), yaml, true);
write(selectedFile, yaml, true);
archived.add(owner);
}

private YamlConfiguration encodeProject(Project project) {
Expand Down Expand Up @@ -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")),
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> track = new LinkedHashMap<>((Map<String, Object>) 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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -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);
Expand Down