15 KiB
Strudel Nostr Save & Share — Architecture Plan
Overview
Add the ability to save Strudel songs, instruments, and sound/sample references to Nostr, and to browse/discover content shared by others. All content is public (no encryption). Users can save their own creations, browse others' shared content, and load it into the editor.
Content Types
1. Song
A complete Strudel program — the full editor content. This is what you'd press Play on.
// Example song content
const bass = note("c1 [~ c1] [~ c1] [~ <c1 g1>]")
.s("sine").gain(0.7).cutoff(300).shape(0.2);
const melody = note("<c4 e4 g4 a4>(3,8)")
.s("sawtooth").room(0.5).lpf(2000);
stack(bass, melody).scope().punchcard()
2. Instrument
A reusable code snippet that defines a sound/pattern. Independent — you paste it into the editor and use it.
// Example instrument
const bass = note("c1 [~ c1] [~ c1] [~ <c1 g1>]")
.s("sine").gain(0.7).cutoff(300).shape(0.2);
3. Sound/Sample Pack Reference
A reference to an external sample pack that can be loaded via Strudel's samples() function. This is a URL pointing to a JSON manifest or a GitHub repo shorthand.
// Example sound references
samples('github:tidalcycles/dirt-samples')
samples({ rave: 'rave/AREUREADY.wav' }, 'github:tidalcycles/dirt-samples')
samples('https://example.com/my-samples.json')
Nostr Event Schema
Kind Selection: Custom Addressable Kinds
We use custom addressable event kinds in the 30000-39999 range. These are parameterized replaceable events — each event is uniquely identified by kind + pubkey + d tag, meaning you can update/replace a song or instrument in place without creating duplicates.
Verified unused (queried across wss://relay.damus.io, wss://nos.lol, wss://relay.primal.net, wss://nostr.wine, wss://purplepag.es — zero results):
| Kind | Content Type | Description |
|---|---|---|
32220 |
Instrument / Code Snippet | A reusable Strudel instrument or pattern snippet |
32221 |
Song | A complete Strudel composition |
32222 |
Sound / Sample Pack | A reference to a sample pack URL for use with samples() |
Using dedicated kinds means:
- Other clients can easily discover and filter Strudel content by kind
- No namespace collisions with other apps
- Semantically clear — kind
32221unambiguously means "Strudel song" - Still fully addressable/replaceable via
dtag
Event Structure
Song Event (kind 32221)
{
kind: 32221,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", "<unique-id>"], // Unique identifier (timestamp-based)
["title", "My Awesome Track"], // Human-readable title
["t", "ambient"], // Hashtag (optional, multiple allowed)
["t", "generative"],
["description", "A chill ambient piece"], // Optional description
["alt", "Strudel song: My Awesome Track"], // NIP-31 fallback for unknown-kind clients
["client", "strudel-ndk"] // Client identifier
],
content: "// Full Strudel code here\nnote(\"c a f e\").s(\"sawtooth\").room(0.5)"
}
Instrument Event (kind 32220)
{
kind: 32220,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", "<unique-id>"],
["title", "Sub Bass"],
["t", "bass"],
["t", "synth"],
["description", "Deep sub bass with sine wave"],
["alt", "Strudel instrument: Sub Bass"],
["client", "strudel-ndk"]
],
content: "const bass = note(\"c1 [~ c1] [~ c1] [~ <c1 g1>]\")\n .s(\"sine\")\n .gain(0.7)\n .cutoff(300)\n .shape(0.2);"
}
Sound/Sample Pack Reference Event (kind 32222)
{
kind: 32222,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", "<unique-id>"],
["title", "Dirt Samples"],
["t", "drums"],
["t", "percussion"],
["description", "Classic TidalCycles dirt samples"],
["r", "github:tidalcycles/dirt-samples"], // The samples() URL/reference
["alt", "Strudel sample pack: Dirt Samples"],
["client", "strudel-ndk"]
],
content: "samples('github:tidalcycles/dirt-samples')"
}
Tag Design Rationale
dtag: Unique identifier (timestamp or random string) — makes the event addressable and replaceable.titletag: Human-readable name for display in lists.ttag: Standard hashtags for discovery/search (NIP-01).descriptiontag: Optional longer description.rtag: For sound events, the actual samples URL/reference (NIP-24 standard).alttag: NIP-31 fallback text for clients that don't know these kinds.clienttag: Identifies our app (NIP-89 convention).
Querying
// My songs
{ kinds: [32221], authors: [myPubkey] }
// My instruments
{ kinds: [32220], authors: [myPubkey] }
// My sounds
{ kinds: [32222], authors: [myPubkey] }
// All my strudel content
{ kinds: [32220, 32221, 32222], authors: [myPubkey] }
// Browse others' songs (no author filter)
{ kinds: [32221], limit: 50 }
// Browse by hashtag
{ kinds: [32220, 32221, 32222], '#t': ['ambient'], limit: 50 }
Data Flow
graph TD
A[Strudel Editor] -->|text selected| B{Selection?}
B -->|yes| C[Save Instrument enabled]
B -->|no| D[Save Instrument dimmed]
A -->|Save Song| E[Save Dialog in Sidebar]
C -->|Save Instrument| E
E -->|title + tags| F[Build Nostr Event]
F -->|publishEvent| G[NDK Worker]
G -->|signed event| H[Relays]
I[Sidebar Songs Box] -->|subscribe| G
G -->|ndkEvent| I
I -->|click song| J[Replace editor content]
K[Sidebar Instruments Box] -->|subscribe| G
G -->|ndkEvent| K
K -->|click instrument| L{Cursor/Selection?}
L -->|text selected| M[Replace selection]
L -->|cursor only| N[Insert at cursor]
UI Design
Design Principles
All save/load/browse functionality lives in the sidebar to keep the main editor area clean. No new buttons in the control panel. The sidebar is the single interface for managing songs and instruments.
Sidenav Layout
The existing sidenav divSideNavBody area (currently has an empty divFiles div) will be repurposed to show two boxes: Songs and Instruments.
divSideNav
├── divMIDISection (existing)
├── divAISection (existing)
├── divSongsSection ← NEW
│ ├── divSongsSectionTitle: Songs
│ ├── divSongsSaveRow
│ │ ├── input: title
│ │ └── button: Save Song (always enabled)
│ └── divSongsList (scrollable list)
│ └── per item: title, click-to-load, delete button
├── divInstrumentsSection ← NEW
│ ├── divInstrumentsSectionTitle: Instruments
│ ├── divInstrumentsSaveRow
│ │ ├── input: title
│ │ └── button: Save Instrument (dimmed when no selection)
│ └── divInstrumentsList (scrollable list)
│ └── per item: title, click-to-load, delete button
├── divSideNavBody (existing)
├── divRelaySection (existing)
└── divVersionBar (existing)
Songs Box Behavior
┌─────────────────────────────────────┐
│ Songs │
│ ┌──────────────────────┐ ┌──────┐ │
│ │ Title... │ │ Save │ │
│ └──────────────────────┘ └──────┘ │
│ ┌─────────────────────────────────┐│
│ │ 🎵 My Ambient Track [×] ││
│ │ 🎵 Drum & Bass Loop [×] ││
│ │ 🎵 Generative Piece [×] ││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
- Save Song: Always enabled. Saves the entire editor content as a song.
- Click a song: Replaces the entire editor content with the song's code.
- [×] button: Deletes the song from Nostr (kind 5 deletion request).
Instruments Box Behavior
┌─────────────────────────────────────┐
│ Instruments │
│ ┌──────────────────────┐ ┌──────┐ │
│ │ Title... │ │ Save │ │
│ └──────────────────────┘ └──────┘ │
│ ┌─────────────────────────────────┐│
│ │ 🎹 Sub Bass [×] ││
│ │ 🎹 Arp Melody [×] ││
│ │ 🎹 Hi-Hat Pattern [×] ││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
- Save Instrument: Dimmed/disabled when no text is selected in the editor. When text IS selected, saves the selected text as an instrument.
- Click an instrument:
- If text is selected in the editor → replaces the selection with the instrument code
- If no selection (just a cursor) → inserts the instrument code at the cursor position
- [×] button: Deletes the instrument from Nostr.
Selection-Aware State Machine
stateDiagram-v2
state Editor {
[*] --> NoSelection
NoSelection --> TextSelected: user selects text
TextSelected --> NoSelection: user deselects
}
state SaveInstrumentButton {
Dimmed --> Enabled: text selected
Enabled --> Dimmed: text deselected
}
state LoadInstrument {
[*] --> CheckSelection
CheckSelection --> InsertAtCursor: no selection
CheckSelection --> ReplaceSelection: text selected
}
Future: Browse Community (Phase 2)
A "Browse" toggle or tab within each box to switch between "My Items" and "Community" views. This keeps everything in the sidebar without adding modals.
┌─────────────────────────────────────┐
│ Songs [My] [Community] │
│ ┌─────────────────────────────────┐│
│ │ 🎵 Cool Track — npub1ab... [+] ││
│ │ 🎵 Ambient — npub1cd... [+] ││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
File Structure
New File: www/js/strudel-nostr.mjs
The main module for all Nostr save/load/browse functionality.
// Constants
const KINDS = { INSTRUMENT: 32220, SONG: 32221, SOUND: 32222 };
// Exports:
saveSong(title, code, tags, description) // Publish kind 32221 event
saveInstrument(title, code, tags, description) // Publish kind 32220 event
deleteItem(kind, dTag) // Publish kind 5 deletion event
subscribeToMyItems(pubkey, kind, callback) // Subscribe to user's items
subscribeToCommunity(kind, limit, callback) // Subscribe to community items
buildEvent(kind, title, code, tags, desc) // Build unsigned event object
parseStrudelEvent(event) // Parse event into display object
getEditorSelection(editor) // Get selected text from editor
insertAtCursor(editor, code) // Insert code at cursor position
replaceSelection(editor, code) // Replace selected text with code
replaceEditorContent(editor, code) // Replace entire editor content
Modified Files
www/strudel.html— Add Songs/Instruments sections to sidenav, import strudel-nostr.mjs, wire up event handlers, track editor selection statewww/css/strudel.css— Add styles for songs/instruments sidebar sections
Implementation Phases
Phase 1: Core Save/Load (Sidebar)
- Create
strudel-nostr.mjswith save/load/editor-interaction functions - Add Songs section to sidenav with save row and list
- Add Instruments section to sidenav with save row and list
- Track editor selection state to enable/disable Save Instrument button
- Implement Save Song (entire editor content → kind 32221)
- Implement Save Instrument (selected text → kind 32220)
- Subscribe to user's own songs/instruments on page load
- Click-to-load: songs replace editor, instruments insert/replace at cursor
- Delete from library (kind 5 deletion)
Phase 2: Browse Community
- Add My/Community toggle to each section
- Subscribe to community events (no author filter)
- Display author npub in community items
- Load community content into editor
Phase 3: Share via Link
- Generate naddr strings for saved items
- Copy-to-clipboard functionality
- Paste/load from naddr input
Technical Considerations
Strudel Code as Content
- Strudel code is plain JavaScript — no special encoding needed
- The
contentfield holds the raw code string - For instruments, the code is the selected snippet
- For songs, the code is the complete editor content
Editor Interaction via CodeMirror
The strudel-editor component wraps a CodeMirror 6 editor. To interact with selections and cursor:
editor.code— get/set the full editor content- CodeMirror's
EditorViewprovides selection state viaview.state.selection view.dispatch()can insert/replace text at specific positions- We need to access the underlying CodeMirror view from the strudel-editor component
Loading Instruments
When clicking an instrument in the sidebar:
- If text is selected → replace the selection with the instrument code
- If no selection → insert at cursor position
- The user can then reference the defined variable in their song
Loading Songs
When clicking a song in the sidebar:
- Replace the entire editor content with the song's code
- No confirmation dialog for now (keep it simple)
Event Size Limits
Relay event size limits are typically 64KB-128KB. Strudel code is text-based and compact, so this should not be an issue. A typical song is well under 1KB.
Replaceable Events
Using custom addressable kinds (32220, 32221, 32222) with d tags means updating a song or instrument (same d-tag) replaces the old version on relays. Each save with a new title/id creates a new event.