Files
client/plans/strudel-nostr-save-share.md
2026-04-17 16:52:51 -04:00

15 KiB
Raw Permalink Blame History

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 32221 unambiguously means "Strudel song"
  • Still fully addressable/replaceable via d tag

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

  • d tag: Unique identifier (timestamp or random string) — makes the event addressable and replaceable.
  • title tag: Human-readable name for display in lists.
  • t tag: Standard hashtags for discovery/search (NIP-01).
  • description tag: Optional longer description.
  • r tag: For sound events, the actual samples URL/reference (NIP-24 standard).
  • alt tag: NIP-31 fallback text for clients that don't know these kinds.
  • client tag: 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 state
  • www/css/strudel.css — Add styles for songs/instruments sidebar sections

Implementation Phases

Phase 1: Core Save/Load (Sidebar)

  • Create strudel-nostr.mjs with 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
  • 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 content field 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 EditorView provides selection state via view.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.