Skip to content

CsdDocument.kt

src/jvmMain/kotlin/afield/audio/csound/CsdDocument.kt · 151 lines

package afield.audio.csound

Reads a Csound box's text as a .csd document and maps box lines to its options, orchestra, score and instruments.

class CsdDocument private constructor

A Csound box's text, read as a .csd — the unified file format Csound has used since the late 1990s: <CsOptions>, <CsInstruments> and <CsScore> inside <CsoundSynthesizer>.

Text with no <CsInstruments> tag is read as a bare orchestra with no score, so a box can be five lines of instr … endin and nothing else.

Every section records the 0-based line of the box text its body starts on, which is what turns a line Csound reports, or a line the cursor is on, into a place in the box.

class CsdDocument private constructor(
    val text: String,
    val options: Section?,
    val orchestra: Section,
    val score: Section?,
) {

data class Section(val body: String, val firstLine: Int)

One section's body and where it starts. firstLine is 0-based in text; lineCount its lines.

val lineCount: Int get() = body.split("\n").size

operator fun contains(line: Int): Boolean = line >= firstLine && line < firstLine + lineCount

True when 0-based box line line is inside this section.

enum class Part { OPTIONS, ORCHESTRA, SCORE, OUTSIDE }

What part of the document 0-based box line line is in.

fun partAt(line: Int): Part

    fun partAt(line: Int): Part = when {
        options != null && line in options -> Part.OPTIONS
        line in orchestra -> Part.ORCHESTRA
        score != null && line in score -> Part.SCORE
        else -> Part.OUTSIDE
    }

val optionFlags: List<String>

<CsOptions> flags, one per whitespace-separated word.

    val optionFlags: List<String>
        get() = options?.body?.lines()?.map { it.substringBefore(';').trim() }?.flatMap { it.split(Regex("\\s+")) }?.filter { it.isNotEmpty() }.orEmpty()

data class Instrument(val names: List<String>, val startLine: Int, val endLine: Int)

An instr block of the orchestra: its names (numbers or names), and 0-based box lines.

val instruments: List<Instrument> by lazy

Every instr … endin block, in order.

    val instruments: List<Instrument> by lazy {
        val out = ArrayList<Instrument>()
        val lines = orchestra.body.split("\n")
        var open: Pair<List<String>, Int>? = null
        for ((k, raw) in lines.withIndex()) {
            val t = stripComment(raw).trim()
            val m = INSTR.matchEntire(t)
            if (m != null) {
                open = m.groupValues[1].split(',').map { it.trim().removePrefix("+") }.filter { it.isNotEmpty() } to k
            } else if (ENDIN.matches(t) && open != null) {
                out += Instrument(open.first, orchestra.firstLine + open.second, orchestra.firstLine + k)
                open = null
            }
        }
        out
    }

fun instrumentAt(line: Int): Instrument?

The instrument whose block contains 0-based box line line, or null.

    fun instrumentAt(line: Int): Instrument? = instruments.firstOrNull { line in it.startLine..it.endLine }

fun instrumentText(instrument: Instrument): String

The orchestra text of instrument, instr through endin.

    fun instrumentText(instrument: Instrument): String =
        text.split("\n").subList(instrument.startLine, instrument.endLine + 1).joinToString("\n")

data class Event(val instrument: String, val line: Int)

A score i statement: what it plays and its 0-based box line. . carries the last p1, as Csound does.

val events: List<Event> by lazy

Every i statement in the score, with carried p1s resolved. Text inside { } loops is counted once.

    val events: List<Event> by lazy {
        val sco = score ?: return@lazy emptyList()
        val out = ArrayList<Event>()
        var last: String? = null
        for ((k, raw) in sco.body.split("\n").withIndex()) {
            val t = stripComment(raw).trim()
            if (!t.startsWith("i")) continue
            val rest = t.removePrefix("i").trimStart()
            if (rest.isEmpty() || !(rest[0].isDigit() || rest[0] == '"' || rest[0] == '.')) continue
            val p1 = when {
                rest[0] == '"' -> rest.substring(1).substringBefore('"')
                rest[0] == '.' -> last ?: continue
                else -> rest.takeWhile { it.isDigit() || it == '.' }.substringBefore('.')
            }
            last = p1
            out += Event(p1, sco.firstLine + k)
        }
        out
    }

val declaredChannels: Map<String, Int> by lazy

chn_k/chn_a/chn_S declarations: channel name to its 0-based box line.

    val declaredChannels: Map<String, Int> by lazy {
        val out = LinkedHashMap<String, Int>()
        for ((k, raw) in orchestra.body.split("\n").withIndex()) {
            CHN_DECL.find(stripComment(raw))?.let { out.putIfAbsent(it.groupValues[1], orchestra.firstLine + k) }
        }
        out
    }

val channelNames: Set<String> by lazy

Every channel name the orchestra uses: declared ones, and the first argument of the bus opcodes.

    val channelNames: Set<String> by lazy {
        val out = LinkedHashSet(declaredChannels.keys)
        for (raw in orchestra.body.split("\n")) CHN_USE.findAll(stripComment(raw)).forEach { out += it.groupValues[1] }
        out
    }

companion object

    companion object {
        private val INSTR = Regex("""instr\s+(.+)""")
        private val ENDIN = Regex("""endin\b.*""")
        private val CHN_DECL = Regex("""\bchn_[kaS]\s+"([^"]+)"""")
        private val CHN_USE = Regex("""\b(?:chnget|chnset|chnmix|chnclear|invalue|outvalue|chn_[kaS])\b[^"]*"([^"]+)"""")

fun stripComment(line: String): String

; and // comments end a line; a ; inside a string does not.

        fun stripComment(line: String): String {
            var inString = false
            var i = 0
            while (i < line.length) {
                val c = line[i]
                if (c == '"') inString = !inString
                else if (!inString && c == ';') return line.substring(0, i)
                else if (!inString && c == '/' && i + 1 < line.length && line[i + 1] == '/') return line.substring(0, i)
                i++
            }
            return line
        }

fun parse(text: String): CsdDocument

Read text as a CSD, or as a bare orchestra when it has no <CsInstruments>.

        fun parse(text: String): CsdDocument {
            fun section(tag: String): Section? {
                val open = Regex("<$tag>", RegexOption.IGNORE_CASE).find(text) ?: return null
                val close = Regex("</$tag>", RegexOption.IGNORE_CASE).find(text, open.range.last + 1)
                val bodyStart = text.indexOf('\n', open.range.last).let { if (it < 0) text.length else it + 1 }
                val end = close?.range?.first ?: text.length
                // The closing tag's own line is not body.
                val bodyEnd = text.lastIndexOf('\n', (end - 1).coerceAtLeast(0)).let { if (it < bodyStart) bodyStart else it }
                val body = if (bodyEnd > bodyStart) text.substring(bodyStart, bodyEnd) else ""
                return Section(body, text.substring(0, bodyStart).count { it == '\n' })
            }
            val orc = section("CsInstruments")
            return if (orc == null) CsdDocument(text, null, Section(text, 0), null)
            else CsdDocument(text, section("CsOptions"), orc, section("CsScore"))
        }
    }
}