灵符lingfu

ling-station

↓ 0 downloads

LingStationX: a DAW in Ling with a piano roll, arranger, performance view, theory-aware score fixing, Qwen instrument choice, Prism / Growl / Synth X / Membrane instruments and Ling-design-system effect windows

Windows · x86-64Linux · x86-64LingOS · x86-64LingOS · Pi 5 ARM64Web · WASM
Keywords daw, music, midi, synth, sequencer, ling
lingfu install ling-station
LicenseApache-2.0 OR MIT
Authorsling-lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (907)

docs/DEV_NOTES.md
# LingStationX developer notes

LingStationX is a DAW written in **Ling** (the language in `../ling`). The audio engine is the native
`ling-daw` crate (`../ling/crates/ling-daw`), exposed to scripts as `daw_*` builtins. All UI is `.ling`
code in `ling/`, drawn with pixel builtins (`px_*`) and the original LingStation font.

Run:  `../ling/target/debug/ling.exe run --interp ling/main.ling` (from this folder)
Test harness (screenshots + scripted input): `python tools/shot.py out.png [crop=x0,y0,x1,y1] -- <app args>`; smoke test of every dialog/tab/menu: `python tools/smoke.py`; extra action for tests: `--do "4,import:C:/path/file.wav"`.

App args: `--shot N` (screenshot at frame N then exit) · `--tab T` (0 arranger, 1 parameters, 2 piano roll,
3 node editor, 4 performance) · `--play` · `--do "frame,action;..."` (run a `do_action` id at a frame) ·
`--drive "frame,x,y,down[,rdown];..."` (synthetic mouse, holds state until the next event) ·
`--scroll "frame,amount;..."`.  Example — click something and look:
`python tools/shot.py out.png -- --shot 30 --tab 3 --drive "10,400,300,0;11,400,300,1;12,400,300,0"`
(double-click = two press/release pairs within ~7 frames; frames are ~20 ms each.)

## Ling language gotchas (learned the hard way)

* No plain assignment: `x = x + 1` is a parse error, write `bind x = x + 1`. Never loop with a step that can be zero or negative (a bad value from `daw_*` getters did exactly that once).
* `daw_*` getters return numbers even for yes/no (`daw_audio_rec_active()` is `0`/`1`): `!daw_x()` never fires — compare `> 0.5`.
* `tools/shot.py` / `smoke.py` use the **debug** ling build by default; set `LING_BIN` to the release exe after `cargo build --release`.
* Test hook: `--do "3,set:recmode0=1"` sets a numeric state key (ticks the Audio record box).
* Numbers are all floats: write `1.0`, `0.0`. `bind x = ...` both declares and re-assigns (also inside `if`/`while`).
* No unary minus: write `0.0 - x`. No `+=`. `&&`, `||`, `!` exist and work inside `if` (this repo's ling patches the parser).
* Booleans: `mouse_down()`, `file_exists()`, `str_starts_with()`, `key_down()` return Bool — compare with `if f() { }`, NOT `> 0.5`.
  Our own `u_*` predicates return Bool; `daw_*` getters return numbers (0/1).
* **Reserved words in every supported language can't be identifiers** (e.g. `si`, `ok`, `can`, `fit`, `form`, `give`, `share`,
  `move`, `copy`, `stop`, `post`, `own`, `where`, `wait`, `again`, `maybe`, `choose`, `change`, `try`, `type`, `start` (entry point only!)).
  If you get `expected LBrace, got Ident`, `unexpected token RParen` etc. suspect a reserved word first.
* Functions are top-level only: `fn name(a, b) { ... return v }`. A function sees **globals defined in `main.ling`** (the `C_*`
  colour constants, `MENU_H`...) read-only, but NOT its caller's locals, and it cannot mutate caller variables. Lists are values.
  => **All shared state lives in the native store: `st_set(key, v)`, `st_get(key, default)`, `st_add`, `st_has`, and list helpers
  `st_lpush/st_lget/st_lset/st_llen/st_ldel/st_lclear`.** Keys are strings; prefix yours (`nd_`, `pf_`...).
* Modules: `use "file.ling"` (path relative to the importing file) imports **functions only**, not `bind` constants.
* `return` inside a nested `fn` ends the whole enclosing block — don't nest functions.
* Strings: `+` concatenates, `num_str(x)`, `to_number(s)`, `str_split`, `str_slice(s, start, LENGTH)` (third argument is a length, not an end index), `str_find(s, sub)` (index or -1),
  `str_len`, `str_upper/lower/trim/replace/starts_with/ends_with`. Lists: `[a, b]`, `list_get(l, i)`, `len(l)`, `list_push(l, x)` (returns new list).
* `println("a", x)` concatenates with no separator. Math: `floor ceil round abs min max sqrt pow sin cos log10 ...`. `rand()` 0..1.
* Errors come with a traceback only under `--interp` (the default JIT backend currently panics on this program — always use `--interp`).
* Text uses ling-ui-free vector font from `assets/font.ttf`: no glyph clipping — use `px_fit`/`g_text_fit` or `u_clip` regions.

* `si` (and other short words that are keywords in another language: `si` = if) cannot be a variable name: `if si >= 0.0` parses as `if if >=` ("unexpected token in expression: Ge").
* `&&` / `||` do not short-circuit: `j < len(xs) && list_get(xs, j)...` still reads past the end ("cannot convert '' to number"). Check the bound first, then read.
* `a < b ... c > d` in one expression can parse as generic brackets: split it (`if`/`else`) when a `<` comes before a `>` on one line.
* One text buffer (`text_set` / `text_poll`) serves every field: `u_textfield` / `u_textarea` load their own value when `text_owner` is not their id; code that fills the buffer itself sets `text_owner`.
## Native helpers added to ling for this app

Drawing (all colours are one packed number `r*65536+g*256+b`; optional last arg = alpha 0..1):
`px_rect(x,y,w,h,c[,a])` `px_outline(x,y,w,h,c[,a])` `px_rrect(x,y,w,h,radius,c[,a])` `px_rrect_outline(...)`
`px_hline(x,y,len,c[,a])` `px_vline(x,y,len,c[,a])` `px_line(x0,y0,x1,y1,c[,a])` (AA) `px_disc(cx,cy,r,c[,a])` `px_ring(cx,cy,r,c[,a])`
`px_theme(0|1)` (1 = Black colour scheme) `px_flat()` (called once at start) `px_text(font,x,y,size,c,text)` `px_fit(font,size,maxw,text)` `px_clip(x,y,w,h)` / `px_unclip()` `px_color(c)` then `draw_poly([x0,y0,x1,y1,...])`.
Files/dialogs: `fs_list(dir)` -> flat `[name, isdir, size, ...]`, `fs_join fs_parent fs_home fs_cwd fs_mkdir fs_copy fs_delete fs_reveal fs_is_dir`,
`dlg_open(title,"ext,ext")` `dlg_save(title,"ext","default.name")` `dlg_folder(title)` -> path or "".
Ling builtins used: `mouse_x/mouse_y/mouse_down/mouse_down_right/mouse_down_middle/mouse_scroll`, `key_down("a")`, `key_pressed("space")`,
`get_width/get_height`, `time_now`, `read_file/write_file/file_exists`, `screenshot(name)`.

## App helper library (ling/)

`core.ling` — input & widgets. Per-frame input is read with `u_mx() u_my() u_down() u_pressed() u_released() u_rpressed() u_double()
u_shift() u_ctrl() u_scroll()`. Hit-test with `u_hot(x,y,w,h)` (respects overlays/clip). Active-widget id: `u_active()/u_set_active(id)`.
Colours: `c_rgb c_mix c_tint c_pal(i)` (ROYGBIV palette) `c_track(t)` (track colour). Text: `g_text(x,y,size,c,s)` `g_text_c` `g_text_r` `g_text_fit(x,y,maxw,size,c,s)`
`g_text_w(size,s)`. Shapes: `g_rect g_rect_a g_frame g_rrect g_rrect_a g_rframe g_line g_disc g_tri g_poly`.
Widgets return their result: `u_button(id,x,y,w,h,label,fillColor|0)` (true on click), `u_icon_button`, `u_chip(id,x,y,w,h,label,on,col)`,
`u_check(id,x,y,label,on)`, `u_tab(id,x,y,w,h,label,on,accentCol)`, `u_hslider(id,x,y,w,h,v,lo,hi,col)` / `u_dragval(...)` /
`u_knob(id,cx,cy,r,label,v01,col,readout)` return the new value or a sentinel — test with `u_has(nv)`. `u_combo(id,x,y,w,h,names,cur)` (dropdown; returns new index or -1; overlay drawn by `combo_overlay()`) `u_scroll_y(id,x,y,w,h,contentH,off)`
draws a scrollbar + handles the wheel and returns the new offset. `u_clip/u_unclip` = clip region that also gates `u_hot`.
`u_meter_h(x,y,w,h,level01)`, `m_level(linearAmp)` -> 0..1, `sel(cond,a,b)`, `clampf(v,lo,hi)`, `fmt1/fmt2`.
`icons.ling`: `icon_draw(name,cx,cy,size,color)` names: play pause stop rec loop chev_r chev_d chev_u plus minus x trash folder file save undo redo eye
layers copy edit arrow_up arrow_down draw select move slice bars note gear info help refresh.
Popups/menus: `popup_list(items,x,y,accent,level)`, `ctx_open(kind,arg,x,y)`; modals: `modal_open(name)`, `modal_close()`, `win_begin(title,w,h)`
(then content origin is `st_get("wx")`, `st_get("wy")`), `u_textfield(id,x,y,w,h,value)`.
Shared state keys you will use: `sel_t` (selected track), `sel_c` (selected clip), `tab`, `status`, `now`.
Always call `daw_undo_push()` right before a user-driven model edit begins (once per gesture, not every frame).

## daw_* API (native, all numeric args; strings noted)

Transport: `daw_play daw_pause daw_stop daw_seek(beat) daw_playing daw_position daw_bpm daw_set_bpm daw_loop_on daw_set_loop(on,start,end) daw_end_beat`.
Tracks: `daw_track_count daw_track_add("synth"|"drums"|"audio", patchPath|"") daw_track_remove(i) daw_track_move(i,delta) daw_track_dup(i,withClips)
daw_track_name(i) daw_track_set_name(i,s) daw_track_inst(i) daw_track_kind(i) daw_track_get(i,"vol|pan|mute|solo|meter_l|meter_r|audio|cr|cg|cb|clips|fx") daw_track_set(i,field,v)`.
Clips: `daw_clip_count(t) daw_clip_get(t,c,"start|len|end|audio|notes|gain|pitch|offset|seconds") daw_clip_set(t,c,field,v) daw_clip_name daw_clip_add_midi(t,start,len)
daw_clip_add_audio(t,path,start) daw_clip_import_midi(t,path,start) daw_clip_remove daw_clip_dup(t,c,newStart) daw_clip_split(t,c,beat) daw_clip_peaks(t,c,n)`.
Notes: `daw_clip_notes(t,c)` -> flat `[start,len,key,vel,pan,cutoff,reso]*N`; `daw_note_add(t,c,start,len,key,vel) daw_note_set(t,c,i,field,v) daw_note_remove(t,c,i)`.
Instruments: `daw_inst_params(t)` -> `[name,value,...]`, `daw_inst_set(t,name,v01) daw_inst_presets(t) daw_inst_preset(t,name) daw_inst_parts(t) daw_inst_part(t)
daw_inst_select_part(t,i) daw_inst_load(t,path) daw_inst_save(t,path) daw_live(t,key,vel,on)`.
Effects (target -1 = master, else track): `daw_fx_types() daw_fx_count(target) daw_fx_name(target,i) daw_fx_bypassed daw_fx_add(target,name) daw_fx_remove daw_fx_move(target,i,delta)
daw_fx_bypass(target,i,on) daw_fx_params(target,i) -> [name,v,...] daw_fx_set(target,i,paramIndex,v01)`.
Routing: `daw_route_count() daw_route_add(kind 0=send|1=sidechain, fromTrack, toTrack)` -> index or -1 (cycle/invalid) `daw_route_get(i,"from|to|kind|amount") daw_route_set(i,"amount",v) daw_route_remove(i)`.
Launcher: `daw_perf_scenes() daw_perf_scene_add(name) daw_perf_scene_remove(i) daw_perf_scene_name(i) daw_perf_scene_set_name(i,s) daw_perf_slot(scene,track)` -> session clip idx or -1
`daw_perf_slot_info(scene,track)` -> `[name,len,isAudio]` or `[]`, `daw_perf_slot_from_clip(scene,track,arrClip) daw_perf_slot_new(scene,track,len) daw_perf_slot_clear(scene,track)`
`daw_perf_launch(scene,track) daw_perf_launch_scene(scene) daw_perf_stop_track(track) daw_perf_stop_all() daw_perf_state(track)` -> `[playingScene|-1, queued(-1 none,-2 stop,scene), progress01]`
`daw_perf_slot_copy(fromScene,fromTrack,toScene,toTrack)` `daw_perf_only()/daw_perf_set_only(0|1)` (Session Only mode) `daw_perf_quant()/daw_perf_set_quant(beats)` `daw_perf_auto(0 smart|1 bar|2 phrase)`.
Automation: `daw_auto_lanes(t|-1) daw_auto_lane_add(t,param) daw_auto_lane_remove(t,i) daw_auto_points(t,i)` -> `[beat,value,...]`, `daw_auto_set_points(t,i,flatList) daw_auto_rec(0|1)`.
Params: `vol` (0..1.5) `pan` (-1..1), instrument param names (0..1), `fx<i>.<p>` (0..1).
Audio clips: `daw_clip_get/set(t,c,"warp")` (source BPM, 0 = off; the clip follows the project tempo with pitch kept) and `"pitch"` (semitones, duration kept) — rendered in the background by WSOLA, `daw_clip_detect_bpm(t,c)`, `daw_clip_get/set(t,c,"loop")` (loop length in beats, 0 = off), `daw_clip_to_track(t,c,newTrack)`.
Recording: `daw_rec_arm(track|-1)` (MIDI), `daw_auto_rec(0|1)`, `daw_perf_rec(0|1)`, `daw_audio_inputs() daw_audio_rec_start(track,""|device) daw_audio_rec_active() daw_audio_rec_level() daw_audio_rec_stop()` -> clip index; live take: `daw_audio_rec_wave(cols)` -> flat `[min,max]*n` of the take so far, `daw_audio_rec_info()` -> `[startBeat, lengthBeats, track]` or `[]`; MIDI in: `daw_midi_inputs() daw_midi_open(i) daw_midi_target(track)`.
Per-part import (MIDI files and FL Studio `.flp` projects share it): `daw_midi_scan(path)` -> flat `[name, program (-1 drums), channel, notes, bank, gm name]*N`, then `daw_midi_import(path, beat, choices)` with one choice per part (`""`/`"auto"`, `"gm"`, `"sf2"` or a `.ling` patch path).
Instrument panels: `daw_inst_get(t, name)` (-1 when missing), `daw_inst_plot(t, "wave"|"filter", n)`. Ling Growl parameter names: `table pos detn stack`, `b wave|rat|fm|lvl`, `sub nois ftype cut res vow drv shaper`, `amp|env a|d|s|r`, `glid wide dely verb out tempo bend whl hold`, lanes `l1..l4 len|int|loop|trig|p0..p15`, matrix `m <src><dst>` (src `l1 l2 l3 l4 env vel whl`, dst `pwfcqvdanr`; value = (amount+4)/8).
Ling Prism parameters: oscillators `a|b wave warp uni det sprd oct fine lvl` (+ `b semi`), `mode amt sub sub w noise noise c hum hum d`, filter `ftype cut res drive keytr fenv comb comb t comb fb`, `amp|flt a d s r`, `l1|l2 rate shp`, tuning `scale root stretch t0..t11`, effects `hz|ph|ch|dl|rv ...`, envelopes `m1..m8 len time loop int x0..x15 y0..y15`, matrix `x <src><dst>` (src `m1..m8 l1 l2 vel key whl`, dst `pit awv awp alv bwv bwp blv amt cut res drv cmb sub nos pan det bpt amp hum spr`; value = 0.5 + amount/2). `daw_inst_get_many(t, [names])` reads many parameters with one engine lock.
Articulate (automation generator): `articulate.rs` knows what a parameter does (`role_of`: filter, resonance, drive, pitch, mix, space, mod depth/rate, timbre, pan, envelope), which are never automated (`eligible`: switches, levels, `voices`, tempo, Talk steps), 15 patterns (wobble, pump, stutter, sweep, riser, build, fade, swell, pluck, open, gate, steps, random, breathe, drift; the per-note ones follow the track's note onsets) and renders them to bar-aligned, sorted, 0..1 points (`render`; lane length capped so a fast cycle over a long range stays light). Builtins (`api/artic_api.rs`): `daw_artic_begin(mode single|random|all, track, count, seed, amount, prompt)` collects candidates in chunks of 10 per track (drums only in `single`; lanes with 2+ points are never replaced), `daw_artic_prompt(i)` / `daw_artic_system()` are the model's question, `daw_artic_apply(i, reply)` writes lanes (model picks where sane, local chooser otherwise; a pick repeated more than twice in a chunk or wrong for the parameter's role is ignored), `daw_artic_info()`, `daw_artic_end()`. The prompt words are read locally too (`read_prompt`: subtle/wild, slow/fast, build/drop/glitch/ambient ... bias the patterns). UI: "Automate..." chip under a track's lanes (automation.ling) opens `modal_artic` (modals.ling); the Qwen loop is `artic_start/artic_tick` in ai.ling; test hooks `artic.open:<t>`, `artic.run:<mode>:<t>:<prompt>`.

Sidechain duck (`dsp/fx.rs` Ducker, `api/duck_api.rs`, `duck.ling`): the existing audio sidechain (a Sidechain route into a Compressor) keys off a whole track's level, so a drum kit's snares and hats duck too. A **Duck route** (`RouteKind::Duck`, `Route.keys` = the MIDI note numbers that trigger it, empty = all; saved as kind "duck" with `keys` in the project) is read from the source track's NOTES: `engine::duck_events` turns every matching note of the source's arrangement clips (loops included, mute ignored) into (frame, velocity) triggers per slice and `Effect::set_triggers` hands them to the target's **Ducker** effect (depth 0-30 dB, attack, hold, release, curve, `thresh` for an audio sidechain key, mix; a harder note ducks a little deeper; a retrigger starts from the current volume). `daw_duck_add(source, target, "36,35")` creates the route and the Ducker when missing, `daw_duck_set/get(target, depth dB, attack ms, hold ms, release ms, curve)`, `daw_duck_list`, `daw_duck_remove(route, remove effect)`, `daw_duck_keys(source)` (Kick 35,36 / Snare / Clap / hats / toms for a kit, every note / low / high otherwise, with hit counts), `daw_duck_shape` for the preview. UI: Edit > Sidechain Duck..., the Duck button in the arranger toolbar, Ducker in the Add Effect picker; the Node Editor draws duck routes labelled DUCK. Session-launcher clips do not trigger ducks (arrangement clips only).

Match key (`keydet.rs`, `api/keytune_api.rs`, `keymatch.ling`): `daw_key_detect` votes every melodic track's notes (`theory::pc_hist`) and every audio clip's chroma (`keydet::chroma`: 4096-point FFT frames, spectral peaks vote for their pitch class, bass counts extra; it also returns the recording's tuning offset in cents and `poly`, the average number of independent pitches, ~1 for a solo line, 2+ for chords) into `theory::detect_key_hist`. `daw_key_set/get` keep the song's key in project meta `song_key`. `daw_key_tracks(tonic, minor)` lists each track with its own key and how much of it is already in the song's key; `daw_key_apply` moves the ticked tracks: MIDI clips in another key are transposed by scale degree (`theory::retune`) and stray notes snapped to the nearest scale tone (`vocal::nearest_allowed`; a minor key also allows its leading tone); melodic audio (voiced >= 30%, steady pitch >= 72%, poly <= 1.7) goes through `vocal::tune_to_scale` - the pitch track is cut into sung notes, each note's median is moved to the nearest scale note and the WHOLE note shifted by that (vibrato stays), and neighbouring notes are joined by a cubic Bezier ease (`bezier_ease(u, y1, y2)`: smooth 0/1, snappy .9/1, lazy 0/.4) of `glide` ms before the PSOLA re-synthesis (`vocal::resynth`, shared with `vocal::tune`); audio mixes get their clip `pitch` set by the smallest whole-semitone move into the song's key (plus the tuning offset when it is over 12 cents). Tuned audio is written next to the project as "<name> in key.wav". UI: the Match key button in the arranger toolbar, Edit > Match Tracks To Song Key..., hooks `key.open`, `key.run:<tonic>:<minor>`.

Fit lyrics (`lyricfit.rs`, builtins `daw_lyrf_*` in `api/lyricfit_api.rs`): paste a song's words, they are cleaned (`clean`: [labels], chord lines, (x2), punctuation), split into syllables (`syllabify_en` by rule - vowel groups, silent e, V-CV, natural onsets - or by the hyphens a model / the user wrote; Hangul / kana / Hanzi per character; `english_phonetic` turns a syllable's spelling into something `latin_to_ipa` reads) and fitted to the clip. Notes are grouped by onset so a chord is one group that carries ONE syllable on every note (or only the top note: `chord=top`). Modes: `fit` (syllables land on strong beats / long notes near their even share, the notes between sustain with `~`; extra syllables go on the longest notes), `even` (by count only), `strict` (one per group). Short notes (< 0.3 beat) after a note become sustains; the rests (gap >= 1 beat) cut phrases and lines are matched to phrases; `loop` repeats lines across phrases and syllables across notes. The clip keeps the pasted text in `lyric_text` and the options in `lyric_lang` = `fit:<mode>:<loop>:<short>:<chord>:<gap>:<lang>`, so editing the text in the strip re-fits. Qwen (`daw_lyrf_system/prompt/accept`) only hyphenates and cleans; `accept_split` keeps its split for every word whose letters still match and splits the rest by rule. UI: `lf_*` in lyrics.ling (Fit... button, `modal_lyricfit`), hooks `demo.fit`, `lf.open:<t>:<c>`, `lf.text:`, `lf.qwen`, `lf.apply`. Ling Voice side: tied (`~`) chord notes slur each voice to the nearest sounding pitch (`claimed`), and a tie after a note-off carries on while the release is still audible.

Derive (Qwen): `daw_derive_prompt(t, seed)` names the instrument and, for Membrane, the drum piece ("Ling Membrane Snare drum parameters ..."); `daw_derive_example(t, seed)` is the worked example turn built from the SAME instrument's parameter names (a fixed synth example made the model answer `waveform` / `filter_cutoff`, which nothing matched, so every Membrane derive fell back to random drift). Level / gain / pan are never derived. Test hook `dv.run:<track>:<part>:<hint>`.

Icons and pickers: `kindicons.ling` (`ki_icon(name, cx, cy, size)`, `ki_col`, `ki_blurb`, `ki_known`) draws a colour-coded tile for every plugin kind (synth, drums, growl, voice, prism, sf2) and every effect (daw_fx_types); used by the Add Effect cards (`modal_fxpicker`, hook `fx.pick:<track>`), the plugin chooser, mixer FX rows, track tiles and the effect window header. Arranger header ticks: `a_follow` (Follow playhead) and `a_loopend` (Loop at last clip: `arr_loop_tick` keeps the loop end on the end of the last clip, whole bars); both are saved in settings.txt (`follow=`, `loop_end=`).

Ling Voice arpeggiator: `arp` (switch), `arp mode` (up, down, up-down, random, played order), `arp rate` (1/4, 1/8, 1/8T, 1/16, 1/16T, 1/32 of a beat), `arp gate`, `arp oct` (1-3 octaves). Held keys go into `arp_keys` instead of sounding; the step clock runs inside `Voice::render` (sample accurate, split at step and gate edges) and plays each step through the polyphonic path (`poly_on`/`poly_off`), so it works mono or with `voices` up. The project tempo arrives through the new `Instrument::set_tempo(bpm)`, which the engine calls before every instrument render. A piano-roll note's lyric is fitted to each step; live notes hold their vowel.

Ling Voice is polyphonic on demand: the `voices` param (0 = mono, 1 = 8) builds a pool of `Voice` instances (`sync_pool`), note-ons pick the slot already on that key, else (tied note) the newest sounding one, else a free one, else the oldest. Other params added: choir (3 detuned glottal sources), open (open quotient), fry, drift, scoop, ring (singer's formant), trem, nasal.

Lyrics (Ling Voice): `daw_lyric_apply(t, c, phrase, lang)` assigns IPA lyrics to the clip's notes in time order, `daw_lyric_ipa(phrase, lang)` previews, `daw_lyric_text(t,c)`, `daw_note_lyrics(t,c)`, `daw_note_lyric_set(t,c,i,s)`, `daw_lyric_script(text)`, `daw_clipboard_get()`. A note's stored lyric is `~` (tied / legato) + IPA syllables joined by `-`.
Export: `daw_bounce(path,start,end,tailSeconds,bits,"wav"|"flac"|"ogg",oggQuality01)`, `daw_midi_export_project(path)`, `daw_midi_export(track,path)`, `daw_clip_import_midi(t,path,start)`.
Project: `daw_new daw_project_save(path|"") daw_project_load(path) daw_project_path daw_project_name daw_set_project_name daw_dirty daw_undo_push daw_undo daw_redo daw_meta_get/set(key)`.

## Design rules (keep the original LingStation look)

Near-black flat chrome (see `C_*` in main.ling), 12 px text, ROYGBIV track colours (`c_pal`), coloured menu titles, rounded mixer strips (8), flat square clips,
1 px separators, hover = brighten, selected = `C_SEL` (0,92,128). Improve where cheap: consistent 4/8 px spacing, hover + pressed feedback, tooltips (`u_tip`), empty-state hints.

## AI instrumentation (Ask Qwen)

`ling/ai.ling` drives ling-ai's `llm_*` builtins (`llm_load`, `llm_generate_start/poll/done`); the build needs `cargo build --release --bin ling --features llm,llm-cuda` (CUDA needs the MSVC environment: run inside vcvars64). Builtins from `ling-daw/src/api.rs`: `daw_ai_available`, `daw_ai_prompt(path)` (the question), `daw_ai_example()` (a worked question/answer pair built by the same code), `daw_ai_roles()`, `daw_ai_choose(path, reply)` (flat `[part, plugin, patch, why]*N` for every non-sample part) and `daw_ai_style(reply)`. The model answers with roles only; `ai_import.rs` owns the role -> plugin/patch table, tolerant JSON parsing, register/over-use sanity checks and the heuristic fallback, so a bad reply never produces an invalid choice. Models live in `assets/models/` (git-ignored). Evaluate with `ling run --interp tools/ai_eval.ling --model assets/models/<gguf>`.

## Prism preset banks

`tools/gen_prism.py` (the original 36) and `tools/gen_prism_fl.py` (Sytrus / Harmor style bank, family builders `pad lead bass pluck arp keys stab bell kick hat`) write `instruments/Prism/*.ling`, `ling-daw/src/prism_presets.txt` and patch the `Prism::PRESETS` array in `dsp/prism.rs`. Run `python tools/gen_prism.py` after editing either; `cargo test -p ling-daw` renders every preset.

## New AI Track (songgen.rs, api/song_api.rs, aisong.ling)

`songgen.rs` (pure, unit-tested): `GENRES` (27: composer style, bpm, Membrane kit, drum pattern, form Club / Song / Score, roles, LUFS, words), `plan(prompt, lyrics, model fields, reference, length, seed)` (prompt > model JSON > reference > genre defaults; key / progression through `compose::parse_fields`), `SongPlan::to_line / from_line` (the editable line; an unchanged structure keeps its energies), `plays(role, section, form, has_vocal)`, `section_notes` (seed = plan seed + part + section tag, so repeated sections repeat; density from energy; drum fills; quiet sections drop kick / snare), `lyrics_for_sections` ([labels] or stanzas; every chorus sings the chorus), `analyze_reference` (bpm folded into 70..180, key from `keydet::chroma` + `detect_key_hist`, 4-bar energy blocks merged into sections (<= 16 bars, long loud runs alternate chorus / drop), chord loop = best diatonic triad per bar over the loudest stretch, collapsed to its period; tone from `mixai::analyze` bands; a genre guess). `api/song_api.rs`: `daw_song_genres/reference/model_system/model_prompt/plan/build`; build resolves patches through `ai_import::genre_candidates` / `role_choice` (+ GM programs for "auto", kits by their `name =`), creates tracks and clips through the normal builtins, fits lyrics with `daw_lyrf_apply`, writes cutoff lanes (`flp_import::map_param("filter cutoff")`) and club pump lanes on `vol`, runs the articulation local chooser on bass / lead, sets `daw_arr_tags_set`, returns the loudness target and brightness for Mix AI. `aisong.ling` is the dialog and a frame-by-frame state machine (Qwen 1-2, reference 20-21, build 10-11, Mix AI 30, RVC 40-41; `daw_rvc_start` with clip -1 sings the whole vocal track). Tests: `cargo test -p ling-daw --lib songgen`, `--test song` (run from LingStationX); app hook `--do "3,as.go:<description without commas>;4,set:as_mix=1"`.

Shaping the song (`section_notes` = `written` (the composer's phrase) -> `develop` -> `lock_to_kick` -> `blend_guide` -> `humanize`):
* Chords: Qwen's JSON has `progression` and `chorus_progression` (the plan line's `chorus=`, "same" = none); `check_prog` reads Roman numerals or letter names, keeps diatonic chords and the usual borrowings (major key: bVII bVI bIII iv; minor: bII, major IV) and secondary dominants that fall a fifth onto a chord of the key, replaces anything else with the key's chord on the nearest degree, adds the tonic when the loop has no home, 2..8 chords; the plan notes say what it changed. The prompt's own chords are never changed.
* Motif: the first bar of a melodic part (lead or the singer) in the first verse / theme (else drop / chorus) is the motif: the chorus opens with it a third up, a bridge with it inverted, a verse that returns ends with its first bar a scale step up, the last return of a section is lifted an octave when the range allows (else it gets the varied ending). Only rewritten bars are fitted back onto the chords (`fit_to_chords`, on-beat notes).
* Bass: unless the pattern sits around the kick by design (rolling, offbeat, walking), a bass note on every kick of the section's drum part (pitch of the line there) plus a few pickups between kicks; only when the drums play there (`arrange`).
* Feel: `human` (plan line, 0..1; genre default from `genre_feel`, "tight/quantized" = 0, "live/loose/band" = 0.85). One timing offset per sixteenth of the song shared by all parts (the bass stays on the kick), bar lines never early, laid-back off-beats and snare in lofi / jazz / dub, rolled chords, accents and per-hit velocity spread.
* Length (`Fit`, the dialog's LENGTH menu; 9th arg of `daw_song_plan`, 8th of `daw_song_build`: "auto" | "lyrics" | "midi" | "duration:m:ss"): `plan_fit` -> `fit_length`. A duration scales the form (`scale_to`: 4-bar steps, min 4 bars, repeated middle sections dropped when still too long, loud sections grow when short); sung sections are never shorter than `bars_for_lyrics` (`SYL_PER_SEC` 2.2 syllables a second + a beat per line, 4-bar phrases; "lyrics" sets them to exactly that), with a duration the wordless sections give the time back; "midi" takes the guide's sections and tempo even at conform 0. The vocal line goes through `fit_line` (about a note per syllable, nothing under a sixteenth, long notes split on the eighth grid, a surplus of short notes merged into sustains) before `daw_lyrf_apply`.
* Lyric shaping (`lyricfit::shape`, option mode "shape": `fit:shape:<loop>:...`; New AI Track and Generate Vocal Track's SING, the default): the lyric lines are laid over the top-note line with the fitter's own `split_lines` DP, then each line gets exactly one note per syllable (longest notes split on the sixteenth grid, never below a sixteenth; surplus notes, shortest interior first, joined to the note before), the line's last note is held (>= 1.5 beats when there is room, <= 4) with a 0.25-0.5 beat breath before the next line; with loop the lines repeat while another round fits. `daw_lyrf_apply` replaces the clip's notes with the shaped ones (velocity etc. from the note sounding there), then fits: one syllable per note.
* `arrange(plan)` -> `on[part][section]` + notes: from `plays`, then a two-part intro, the drums or bass out in a bridge (not club breaks), a returning verse gains a part, the first chorus holds back an arp / second pad, the last chorus has every chorus part, never an empty section. `daw_song_build` uses it.

## Voice Skin (RVC)

`rvc.ling` + ling-daw `api/rvc_api.rs` (`daw_rvc_info/models/start/poll/cancel/original/restore`). One background job: the source (an audio clip's sample, its original if it was skinned before; or the track soloed and bounced over a MIDI clip) is written to `<project>/audio/rvc/rvc_<id>_in.wav` (cache/rvc without a project) and `<rvc>/.venv/Scripts/python.exe tools/rvc_skin.py ...` runs with the RVC folder as cwd (all paths absolute; no console window). The script mirrors the WebUI's vc_single (the install's `infer_batch_rvc.py` calls `load_audio` with 2 args, which this Mangio fork's 5-arg version breaks), clears sys.argv before the install's Config, and prints PROGRESS / DONE lines that poll reads from the job log. On success the input copy and log are deleted; the result replaces the clip's sample (meta `rvc.orig.<result>` = original, for Restore) or goes on a new audio track (source muted); an undo step is pushed first. Native engine (`script` = "native", needs the `llm` feature; `daw_rvc_info` item 3 says whether it can run): `ling-ai/src/rvc.rs` is RVC v2 on candle - HuBERT (fairseq checkpoint, layer 12, features doubled to 10 ms), the TextEncoder with relative attention, the reverse coupling flow and NSF-HiFiGAN, weight norm folded at load, config read from the .pth pickle; conversion in 20 s windows with 1 s context and a 50 ms crossfade, RMS envelope mix. Pitch comes from `vocal::track_pitch` (YIN, every 2nd 5 ms frame), not RMVPE; the .index (faiss) and protect are not used. `rvc_api.rs` runs it in a thread (`Run::Native`, progress in an AtomicU32, the loaded model cached for the next job); cancel drops the thread's result. Checked against the WebUI on the same input and RMVPE pitch: log-mel correlation 0.993 (the input against the WebUI: 0.768), median pitch difference 0 cents, 2.2 s converted in 0.23 s on CUDA. Engine tests: `cargo test -p ling-ai --release --features llm rvc -- --ignored` (RVC_CMP = a folder with in.wav + f0.csv). The UI default is NATIVE (`rvc_engine` 0 in settings.txt). Test: `cargo test -p ling-daw --test rvc` (uses E:/SannyChat/talk + LingLin, about 15 s); app hooks `--do "2,demo.voice;5,rvc.go"`, `--do "5,vg.rvc;6,vg.go:1"` (Generate Vocal Track + RVC).

## FL automation, Membrane kit bus

FL automation clips: `flp_import::automation` re-reads the .flp (automation channel = type 5; event 234 = 21-byte header with the point count as u32 at offset 17, then 24 bytes per point: delta beats f64, value 0..1 f64, tension f32, flags u32; placed by playlist items whose index is the channel) and splits the display name (203) "Target - Param". `map_param` matches FL parameter words to the instrument's knob names through a synonym table (`PARAM_SYN`; Mod X = cutoff, Mod Y = reso), falling back to vol / pan / bend. `daw_midi_import(path, beat, choices, mode, over)`: mode "map" | "lanes" | "skip", over = [part or -1, mode] per clip; unmapped lanes are `fl:<name>`. `daw_midi_autos(path)` lists clips for the dialog (`imp_autos_rows` in importdlg.ling); `daw_auto_lane_set_param(t, lane, param)` re-points a lane (Automation lane header -> "autoremap" menu). Tests: `flp_import::auto_tests`, `tests/fl_auto.rs` (on the user's Aurora.flp).

Membrane (`dsp/drums.rs`): per-piece `sub snap ring fold vary` (all 0 = off; vary draws random numbers only when on, so old kits render bit-for-bit) and the kit bus `bus duck/room/glue/smash/tape/boom/air` (`BusParams`, state keys `bus.*`, always saved; bypassed when neutral; the kick piece is routed separately so it can duck the rest). Tests in `tests/membrane.rs`.

## Vocal editor (vedit.ling, ling-daw api/vedit_api.rs)

Builtins `daw_ve_*`: info, analyze (YIN pitch + `detect_notes` -> blobs), blobs (9 numbers each: src on/off, dst on/off in seconds on the recording's time line, detected pitch, shift semitones, drift 0..1, gain, level), curve (pitch after the edit + level across a blob, for drawing), set (selection string "i,j"; keys shift dshift drift gain dgain move on off), split, merge, correct (scale / tonic / temperament), quantize, guides + align (MIDI part, or another edited vocal), opt (vdepth vrate retune temp troot), render, compare, reset, to_midi. The edit is project meta `ved.<id>` (+ `.src` original path, `.blobs`, `.out` last render) and the clip tag `ved:<id>`; undo snapshots it like everything else. Render = vocal.rs `render_segments`: a time map from both ends of every blob (gaps between notes stretch), a pitch-ratio grid per 5 ms (shift + drift toward the blob centre + vibrato, smoothed by the glide), then `resynth_n` (PSOLA into any output length) and per-note gain with 10 ms ramps. Renders are `audio/vocal_edit_<id>_<rev>.wav`; only the last 8 per vocal are kept. An untouched edit plays the recording itself. Analysis is cached per recording path in memory. Test: `cargo test -p ling-daw --test vedit`; app hook `--do "2,vox.demo:<wav>;6,ve.do:up;8,ve.do:stretch;10,ve.do:render"`.

## Build script (tools/build.py)

One Python script for every edition: `win`, `lin`, `mac`, `web`, `lingos-x86`, `lingos-pi5`, `all`. It drives `ling build` (`../ling/target/release/ling`, or `--ling` / `LING_BIN`; `LING_HOME` is set so the wrapper project finds the Ling source tree) and stages the result like `tools/make_portable.ps1` did (launcher + ling/ instruments/ assets/ library/ scripts/ + empty projects/ cache/ renders/ assets/models/), then zips (win, mac) or tars (lin). Facts the script is built on: `ling build` writes its wrapper Cargo project to `.ling-build/ling-station/` for every platform, and a kernel build leaves `.cargo/config.toml` (build-std) and `x86_64-lingos.json` there that break the next desktop build, so the script deletes them first; `LING_APP_INTERP=ling` makes the launcher load `ling/main.ling` next to the exe at run time; `LING_APP_FEATURES` picks the ling crate's features inside the launcher (`llm`, `llm,llm-cuda`; unset = none); a cross-compile from Windows to Linux needs `cross` + Docker, so the script compiles the Ling compiler and the launcher inside `tools/docker/linux-builder.Dockerfile` instead (cargo caches on named volumes `lingstationx-*`); macOS has no cross path at all. LingOS: the kernels are built with `ling build <LingOS>/kernel/<name> --platform kernel|rpi --out <LingOS>/dist` (nightly, build-std; `kernel/x86_64-wm` and `kernel/rpi-lingstation` carry `bundle_app = "lingstation:../../../LingStationX/ling/main.ling"`, the script warns when that is not this checkout), then LingOS's `live/build-iso-x86_64.sh` / `live/build-sdcard-rpi.sh` run in the first WSL distro that has grub-mkrescue / xorriso / nasm / mtools (on this machine: Arch, not the default Ubuntu), natively on Linux, or in `tools/docker/lingos-pack.Dockerfile`; `kernel8.img` needs `llvm-objcopy` on PATH. The web bundle (`lingc webgl` + wasm-pack) builds, but `src/runtime/mod.rs` gates `mod daw` (daw_* and px_*) behind `cfg(not(target_arch = "wasm32"))`, so it is the UI program without an engine. `.github/workflows/build.yml` runs the script per OS; it has not been run on GitHub yet.

Publishing: `.github/workflows/build.yml` builds win, lin, mac (macos-latest arm64 + macos-13 Intel, named `-macos-arm64` / `-macos-x86_64`), web and both LingOS images; a pushed tag `v*` adds a `release` job that attaches the zips, tar.gz, ISO, Pi boot image, kernel8.img and config.txt (and the web preview if it built) to the GitHub release. Desktop builds use `--features llm` (CPU). It checks out `taellinglin/ling` at `ling_ref` (default `lingstationx`) and `taellinglin/LingOS` at `lingos_ref` (default `master`): those must be pushed first (on 2026-10-23 GitHub had only ling `master`, and LingOS master was 19 commits behind the local one; LingStationX itself had no remote).

## lingfu publish (the fu.ling-lang.org build farm)

`lingfu publish` uploads the source (ling.ignore applies); the registry then runs `ling build /work/src --platform <p> --out /work/out` per platform in `fu-build-runner:<toolchain>` (no network, 2 GB, 2 CPUs, offline cargo cache) and zips `out/<dir>`. The toolchain is `[root] core = { version = "2030.1" }`; its platforms are the `ling_toolchains.platforms` column (web, lin, win, lingos, kernel, rpi; mac only when the image has osxcross). `targets = ["win", "mac", "lin", "kernel", "rpi"]` in Ling.toml picks the platforms: `ling build .` with no --platform builds them (mac is skipped off a Mac), and the farm builds the manifest targets its toolchain supports (aliases lingos-x86_64 = kernel, rpi5 / lingos-pi5 = rpi; no `targets` line = every toolchain platform). What the manifest does for it: `portable = "ling"` (desktop exes run ling/main.ling from the folder beside them, like tools/build.py's LING_APP_INTERP), `includes` starts with ling/*.ling and scripts/*.ling, `entry_os` + `includes_os` (LingOS targets embed only the fonts and the startup project), `graphics = true` (the x86 kernel draws on a framebuffer). Outputs: lin `linux/ling-station`, win `windows/ling-station.exe` (mingw), kernel `kernel/ling-station` (Multiboot2) + `ling-station.iso` (GRUB, when the image has grub-mkrescue), rpi `rpi/kernel8.img` (Pi 5), lingos `apps/ling-station.elf` (a ring-3 app: LingOS cannot launch app ELFs yet, so the bootable `kernel` build is the x86 LingOS download). Checked on 2026-10-23 by reproducing the farm locally (scratchpad farm.py: same packaging and docker run): win exe runs the app (headless screenshot), the x86 kernel and the Pi kernel boot into LingStationX in QEMU (x86 screenshot; raspi3b serial). `ul_slider` / `ul_check` are drawn in Ling (the LingOS runtime has no ui_slider / ui_checkbox). mac: put Xcode_*.xip (Apple ID download) or MacOSX*.sdk.tar.xz in ling/docker/macos-sdk, rebuild the image, add mac to the toolchain's platforms.

## Registration and Save to Cloud

register.ling: `reg_verify(text)` splits a license file at `sig-mldsa87=`; the bytes before it are the signed message, and both `mldsa87_verify` and `ed25519_verify` must pass against the public keys in `reg_pub_*` (made by studio.linglin.art `tools/licenses.ling --action keys` from its `license_signing.seed`; a new seed means new keys here). Subscription keys are `SUB-<user id>-<sub_until unix>`; expired ones are refused. The accepted file is `license.lsxlicense` next to the app (ling.ignore / .gitignore). Registered: `reg_label()` in the title bar / About, `wp_draw` paints `wp_path` over the centre view at `wp_opacity` (image_draw's new 10th argument multiplies the image alpha). The LingOS runtime has no mldsa87_*, so a LingOS build stays unregistered (everything still works). New runtime builtins: `mldsa87_keygen(_from_seed)`, `mldsa87_public`, `mldsa87_sign`, `mldsa87_verify` (the older `mldsa_*` are ML-DSA-65).

cloud.ling + ling-daw api/cloud_api.rs: `daw_cloud_login / me / upload / poll / lyrics`, one background request at a time (ureq, rustls). Upload = the saved project's folder zipped (not renders/, cache/, backups/, *.lsxlicense) as multipart `artifact` + fields title, artist, genre, tags, description, lyrics, visibility, note, project (slug: a new version). The slug is kept in the project meta `cloud.slug`. Site side: POST /api/login (returns the API token), /api/me (display, subscriber), /api/upload stores the fields (projects.artist / lyrics columns, shown on the track page). `_pw` text fields draw stars (u_textfield). Checked against a local copy of the site (port 8473): sign-in, upload with every field, the track page showing artist / description / lyrics.

## Tracker module export (tracker_export.rs, daw_trk_export, trackerpdf.ling)

File > Export Tracker ticks MOD / S3M / XM / IT / MPTM / PDF. `daw_trk_export(dir, base, "mod,s3m,xm,it,mptm", "rpb=4;seg=4;roots=6;title=")` builds an offline engine from `project::to_doc` (like the render job) and a `Renderer` shared by all formats: instrument tracks are sampled with `inst.note_on(key, 1.0)` held for the track's median note length (0.4-2.5 s, kits 0.25 s) plus the release until -74 dB (max 4 s), dry, track level baked in; audio tracks are bounced solo (master effects off) and cut into `seg`-beat segments named "<track> <bpm>bpm NN" (identical segments share a sample). Keymapped formats (XM 16 samples per instrument, IT/MPTM 120) sample every `roots` semitones over the used range; MOD / S3M one sample (the median key) per melodic track; drum kits (`inst_kind == "drums"`, or a percussive name with <= 24 keys) a sample per key. `find_loop`: sustained (level at 80-100 % of the held part >= 25 % of the peak) -> whole periods of the key's pitch (~0.33 s), upward zero crossings, best waveform match before both ends; IT/MPTM keep the release after a sustain loop, the others end at the loop. Budget (MOD 31, S3M/IT 99, MPTM 240 samples; channels 32/64): segments double, then octave roots, one root, fewer kit keys, then tracks are dropped (all in the report). Samples are normalized together (one gain). Pitch: rate at MIDI 60 = C5Speed (IT), C2Spd with S3M note = MIDI - 12, XM relative note + finetune vs 8363 Hz (XM note = MIDI - 11), MOD resampled to 8287.14 Hz * 2^((root + shift - 60)/12) with notes moved into MIDI 48..83. Rows: speed 24 / rpb, tracker tempo = BPM, tempo map -> T / Fxx; note ends -> note-off (IT, XM) or cut (S3M, MOD) for melodic parts; MOD / S3M end with a pattern break when the last pattern is short. IT mix volume and S3M master volume fall with sqrt(channels) above 6. Verified with libopenmpt (ffmpeg -f libopenmpt): 220 Hz test tone exact in every format, envelope correlation 0.96 and chroma 0.81-0.94 against the DAW bounce, a real 9-track project 144 s with no clipping. Tests: `cargo test -p ling-daw --lib tracker_export` (round trip through our importer); `--ignored writes_files_for_libopenmpt` with TRK_OUT=<dir>. App hook `--do "5,trkx.go:<folder>"`.

## Text size

`font_scale` (0.7-1.6, settings.txt): `g_text` / `g_text_w` / `g_text_fit` / `g_text_wrap` in core.ling scale the size and grow the text around its middle (`text_k()`, `text_size_set()`). Set with the A- / % / A+ buttons at the right of the view tabs, Ctrl+Shift+= / Ctrl+Shift+- (Ctrl+= / - stay zoom), Ctrl+0, or Settings > General. Layouts are fixed in pixels, so tight rows crowd above about 130 %; text drawn straight with px_text (tracker grid, lyrics, karaoke) does not scale.

## Konnarian font (Tracker)

`assets/fonts/KonnarianTrue.otf` is Daemon stacked into syllable blocks on the Hangul code points (U+AC00.. plus the jamo); each piece is a Daemon letter under the Revised Romanization (its ㄱ is Daemon G, ㄴ N, ㄷ D, ㄹ R / L, ㅁ M, ㅂ B, ㅅ S, ㅈ J, ㅊ C, ㅋ K, ㅌ T, ㅍ P, ㅎ H, ㅏ A, ㅓ E, ㅗ O, ㅜ U, ㅣ I). It also has ♭ ♮ ♯, superscript digits, its own numerals and the consonant letters; its vowel letters and `- = # + ^ & * _` are zero-width stacking marks. `ling/konnarian.ling`: `kn_hangul` (romanized text -> blocks: onset / longest vowel spelling / final cluster, English spellings normalized `c ck q -> k, th -> t, ph -> p, sh -> s, x -> ks, f -> p, v -> b, z -> j`, doubled consonants collapsed except ss kk), `kn_solfege` / `kn_sharp` / `kn_sup` (a note is 도 레 미 파 솔 라 시 + ♯ + raised octave), `kn_ascii` / `kn_plain` (what the font draws vs. what goes to the normal font). `ling/hangul.ling` is GENERATED by `python tools/gen_hangul.py` (Ling strings have no `\u` escapes and no builtin makes a character from a code point, so the 11172 syllables are written out). Tracker mode 3 draws blocks at the cell size, the font's half-height numerals and letters a quarter larger on the same baseline, and the ♯ / octave marks at 2.5x. Test hook: `--do "2,trk.demo;4,set:trk_font=3"`.

## Theory assistant, composer, style

`ling-daw/src/theory.rs`: Krumhansl-Schmuckler key detection with a Viterbi over 24 keys for key changes (switching costs 1.0, the global key gets a small bonus, low notes count extra so relative major / minor settle), chord detection by template matching over a 2- or 4-beat grid, Roman numerals, a role per note (`R_CHORD..R_OUT`) with a suggested replacement, phrases, `next_chords`, `voice` (voice-leading), `autocorrect` (mask `FIX_KEY|FIX_TENSION|FIX_TIMING|FIX_DUPES|FIX_PASSING`), `retune`. `compose.rs`: `Spec` (from keyword text, a model's JSON, or "k=v;k=v" fields; later layers win), seeded generators (`gen_melody/chords/bass/arp`), `thumbprint` -> story + fields, `spec_from_style`. `api/theory_api.rs` exposes it (`daw_th_*`, `daw_style_*`, `daw_lib_*`); the clip analysis is cached per project revision so the roll can ask every frame. UI: `ling/theory.ling` plus hooks in `roll.ling`; the model call for "Ask Qwen to write" is `cmp_*` in `ai.ling` (the model's `key` / `progression` are ignored unless the request names them, small models invent them). Test hooks: `text:key=words`, `cmp.write`, `cmp.ai`, `lib.scan`.

Gotcha found while testing: minifb's `get_mouse_pos(Clamp)` panics on a window with no client area (minimised); `ling/src/runtime/mod.rs` now guards it.

## Timeline, patterns, derive

`ling/timeline.ling` is the shared ruler (`tl_input`, `tl_ruler_marks`, `tl_numbers`, `tl_region_*`): the selected range lives in project beats (`rg_on`, `rg_a`, `rg_b`), `mode` 0 = arranger, 1 = roll (origin = clip start). Labels step by powers of two so they are >= 44 px apart and fade out below ~3 px per unit. `ling/pattern.ling` is the Pattern tab; the generators are `compose.rs` (`gen_drums`, `rhythm_mask`/`euclid`, `arp_notes`, `arpeggiate`) reached through `daw_th_generate` (mode 0 replace the clip, 1 add, 2 replace only the written stretch) and `daw_roll_arp`. Derive: `daw_derive_prompt(t, seed)` returns a JSON object of 24 parameters (sound-shaping ones first), the model must return it with new values (temperature 0.3, a small model otherwise invents its own names), `daw_derive_apply(t, reply, gain)` writes known names only (gain 2.5 stretches the small nudges), `daw_derive_local` is the model-free fallback; `tools/derive_eval.ling` compares prompt layouts.

## Fix all, sections, vocal tuning, vocal effects

`ling-daw/src/arrange.rs` (pure): `plan_clips` (onsets / durations / overlaps / gaps against the grid most clips sit on) and `detect_sections` (per-bar features -> energy, boundaries at clear changes, tags by rules; four-on-the-floor music gets "drop" instead of "chorus"; fills are 1-bar markers). `api/fix_api.rs`: `daw_fix_scan(mask)` (first pass only, per clip), `daw_fix_run(mask, passes)` (repeats `theory::autocorrect` through `analyze` until a pass changes nothing; stops early if two passes in a row do not reduce the work), `daw_fix_report`, `daw_arr_scan/fix(mask)`, `daw_arr_sections`, `daw_arr_tags_set/tags` (project meta `sections` = "start:end:tag;..."). UI: `ling/fixall.ling` (the docked sidebar; the ruler tags are `arr_tags_draw`). `vocal.rs`: YIN on a 12 kHz copy -> f0 every 5 ms, voiced starts refined with a 1 ms envelope, a piecewise-linear time map from note starts, a correction contour (octave-folded, smoothed, optional vibrato), PSOLA with a single peak polarity per voiced run (alternating peaks made the output sub-periodic) normalised by the summed window; `api/vox_api.rs` (`daw_vox_sources`, `daw_vox_tune`) writes the tuned take as a float WAV in `<project>/audio/` (or `cache/vox/`). `dsp/vox.rs`: `Vox Filter`, `Vox Reverb`, `Vocal Halo`, registered in `dsp/fx.rs` (`EFFECT_NAMES`, `defaults`, `make`). Test hooks: `test.mess`, `fa.open`, `fa.run`, `fx.add:<name>`.

## Genre-aware instrument choice and the batch

`ai_import.rs`: `GENRES`, `genre_prompt`/`parse_genre` (the first question), `guess_genre` (tempo, drums, harmony, names: the no-model fallback), `genre_candidates(genre, role)` (a genre's own plugins before the general role table; `("auto", "")` keeps acoustic parts on General MIDI), `fl_plugin_hint` (what an FL plugin is, added to each part's description via `ImportedTrack.plugin`), `choose_for(mid, reply, genre)`. Builtins `daw_ai_genre_prompt/genres/parse_genre/guess_genre`; `daw_ai_choose(path, reply, genre)`. `ai.ling` asks the genre first (24 tokens), then the roles (`ai_plugin_guide()` tells the model what the plugins are). Defaults: `default_comp` (per track: 2.5:1 at -14 dB, +1 dB) and `ensure_master_comp` (2:1 at -10 dB, slow) in `api.rs`, applied by `daw_new`, `daw_track_add` and the MIDI / FLP import, never when a project is loaded.

Windows Controlled Folder Access blocks writes under `Music` for programs it does not know (ling.exe and python both fail silently), so the batch writes to `C:/Users/User/LingStationXProjects` unless `--out` says otherwise; allow ling.exe under Windows Security > Ransomware protection to write into Music.


## Structure, order weighting, effect automation, harmonizer

`structure.rs` (pure): `best_page` (1/2/4/8-bar medoid page and how alike the pages already are), `loopify` (rebuild N beats from one page with `variation_level` turns every 4th / 8th repeat), `plan_statements` (a riff gets two statements, each at most 32 bars and a multiple of 8; a single statement in a long song is repeated in the next high-energy section). `api/fix_api.rs`: `daw_arr_struct_scan(mask)` -> `[track, clip(-1 = whole track), name, pages|riff|skip, text]*`, `daw_arr_struct_apply(mask)` (mask 1 = pages, 2 = riffs); `daw_fix_scan(mask, order)` / `daw_fix_run(mask, passes, order)`. `theory.rs`: `prominence`, `key_sections_vote` (per-instrument weighted majority vote), `autocorrect_ctx` (`order` 0..1 scales the tolerance for out-of-key / timing). UI: `fixall.ling` (`fa_order`, `fa_stp`, `fa_str`).

`dsp/hypergrowl.rs`: `PARAMS` (44, order = knob order = automation index), `TABLES` (12), `PRESETS` (8), `table_gain(table, x, morph)`, 24 zero-delay-feedback band-passes per channel (`Band`), a 32-grain delay on a 2 s buffer (`Grain`, shared scheduler so grains pan across L/R), a feedback comb, an in-file radix-2 FFT and `Spectral` (2048 points, sqrt-Hann, 4x overlap; moving-average envelope `(env/mag)^(0.9*squash)` clamped 0.05..6, bin shift, gate, per-bin gain blur, tilt); latency `LATENCY` = 2048, the dry ring matches it. `Effect::view()` (default empty) feeds `daw_fx_view(target, fx)`; for the growl it is 24 band gains + the LFO + the live grain count. Run `HG_DUMP=<dir> cargo test -p ling-daw --lib dump_presets -- --ignored` to write one WAV per preset (the spectrogram script in the session notes plotted them: formant arcs should move on Hypergrowl / Yoi Bass, stepped bands on Digital Chop).

Effect windows: `ling/fxpanel.ling` (`modal_fxview`). `fxp_spec(name)` lists sections as `"title|colour key|columns|tokens[|n]"` (a token is a knob, `@param:a/b/c` a choice - tiles up to five, a stepper beyond - or `~bands`; `n` starts a new row); `fxp_flow` measures and draws; `fxp_read` / `fxp_tip` give readouts and tooltips; knob ids are `fxk<param index>` so `knobmenu.ling` can resolve them. Builtins `daw_fx_view`, `daw_fx_presets(name)`, `daw_fx_preset(target, fx, i)`, `daw_fx_reset`, `daw_fx_random(target, fx, seed)`. Test hook `fx.view:<track>:<fx>`.

Automation: `engine.rs` `apply_automation` drives master effects from `master_auto` lanes named `fx<i>.<p>` (touch-protected like track lanes); `daw_fx_set` records master moves; `daw_auto_lane_add(-1, "fx<i>.<p>")` is accepted. New builtins `daw_inst_default(t, name)` (a fresh instrument of the track's kind), `daw_fx_default(target, fx, param)`, `daw_auto_shape(target, lane, shape, from, to, period, lo, hi, seed)` (`sine tri up down pulse steps random`, replaces the lane inside [from, to]). UI: `ling/knobmenu.ling` - `ds_knob` / `u_knob` call `kn_open(id)` on a right click; the id is mapped back with `kn_resolve` (`fxk<i>` = the effect in the effect window, `gk<name>` / `pk<name>` / `pk<track>_<name>` / `pk<track>_<part>_<name>` = instrument params). Test hooks `kn.open:<id>:<track>:<fx>`, `kn.do:<id>:<track>:<fx>:<action>`.

`daw_notes_apply(t, c, mode, selection, origins, origin lengths, p1, p2)` moves / resizes / stretches a whole piano-roll selection in one engine call (`roll_apply_*` use it; a call per note made 1000+ note selections lag). The roll also builds a per-frame selection flag list (`roll_mark_sel`, `roll_sel_flag`) instead of scanning the selection for every note.

`harmonize.rs` (pure): `parse_prompt` -> `Spec { voices, sustain, level }` (`Voice::Steps(n)` scale steps, `Stack(n)` n-th chord tone below, `Bass`, `Counter`), `harmonize(melody, chords, keys, main, spec)`. Builtins `daw_th_harm_summary(prompt)`, `daw_th_harmonize(t, c, selection, ref track or -1, prompt, dest track)` (writes a new "Harmony" clip). UI: Compose > Harmony (`th_harmony_tab`, `hm_run`); test hook `hm.run:<prompt>`.


## Fixer evidence rules, slicing, crash capture

`theory.rs`: `FixCtx` carries `clash` (per note, from `clashes_with_others`: a half step / major seventh against another instrument within 14 semitones, notes of 0.5 beat or more) and `others` (the other instruments' notes, sorted). `autocorrect_ctx`: out-of-key notes need `grinds || (len >= 0.75 && !in_local_triad)`; tension fixes only at order > 0.85; passing notes only with mask bit 16; a fix target that would clash is replaced by the nearest clean in-key pitch or skipped. `note_roles`: a tension suggestion must be in the key; an out-of-key note of at most 0.76 beat stepping a half step onto a note of the key (next or previous DIFFERENT pitch) is an ornament (`R_PASS`). `merge_short_keys(keys, 20)` runs in `analyze`. `daw_fix_run` analyses every clip once and reuses that key / chords for all passes. Scripts use mask 15, not 31. `tests/anthem_dump.rs` (ignored; `dump`, `diff`, `show`) prints what the analysis finds and which notes a fix run changes in a MIDI file.

`structure::slice_plan(notes, bars)` -> `SliceSeg { start_bar, page_bars, repeats, group }`; `daw_arr_slice_scan(min_bars)` / `daw_arr_slice_apply(min_bars)` (fix_api.rs) replace each long clip by its slices (looping clips with `loop_len` = one page). `daw_arr_name(mask)` (1 clips, 2 scenes, 4 session clips, 8 tidy track names) names things after the section tags, keeping the pattern letter. `perf::auto_scenes` mode 3 cuts scenes at section tags. `tools/make_startup.ling` builds the startup template (Qwen genre + roles -> import -> fix -> slice -> arrange -> tags -> scenes -> names -> save); `choose_for` ignores the model for acoustic General MIDI programs (< 80) in orchestral / cinematic / folk / jazz.

Crash capture: `run.bat` writes `lingstation.log`; every panic is appended with a backtrace to `lingstation_crash.log` (`install_panic_log` in api.rs); the audio thread catches engine panics (`audio.rs` `Resampler::pull`) and every engine lock recovers from poisoning. `tests/soak.rs` (ignored) plays every project for minutes of audio offline and reports panics / non-finite samples.


## Hypergrowl sample section, Growl talk / sync

`dsp/hypergrowl.rs`: params 44..49 (`smix spos sscan xsyn freeze gsrc`, appended so older indices hold), `SampleBank` (mono audio at the effect's rate + per-frame smoothed spectral envelope (2048-point, up to ~380 frames) + 24 band gains), `default_sample` (a 3 s sung-vowel glide), `Hypergrowl::load_sample`. `Effect::load_source(path)` / `source()` (trait defaults do nothing); `FxDoc.source` is saved (relative paths resolve against the project) and reloaded by `fx_slot`. Builtins `daw_fx_load(target, fx, path)` ("" = built-in), `daw_fx_source`. `daw_fx_view` is 51 values: 24 bank gains, LFO, grain count, the sample's 24 gains at the reading position, the position. Cross-synthesis multiplies each bin's gain by `(sample env * level match / input env)^xsyn`; freeze keeps magnitudes and advances phases like steady partials. UI: `fxpanel.ling` sample section (`^file` token, `fxp_file_row`).

`dsp/growl.rs`: parameters after the matrix (`EXT_BASE`): `talk, t rate, t glid, t len, t res, t sh, sync, s mod` then `t s0..t s15` (16 vowels, A 0 .. U 1); `state()` writes extras only when not at their default. Talk = a step clock (`bps * T_RATES`), a smoothed vowel position and three formant filters after the main filter; Sync = master phase at the note pitch resetting the stack, which runs `1 + (sync + lane3 * s mod) * 7` times faster (wavetable level chosen for the faster rate). `growl_presets.txt` and `instruments/Growl/*.ling` were generated by a script (shapes + matrix routings); `Growl::PRESETS` lists all 42. UI: `growl.ling` `gr_talk` (the draggable step strip).

## Tracker import, stems bundle

* `tracker.rs`: `parse_module` reads MOD / S3M / XM / IT (+ MPTM, IT214/215 decompression) into `Module` (instruments with samples + key maps, pattern cells); `play` walks orders/rows (4 rows per beat, bpm = 6*tempo/speed, jumps, breaks, repeat guard) into notes per instrument and a tempo list; `write_sf2` writes one preset per instrument (zones from the key map, loops, pan, fine tune, fade-out as release, root key = 60 - IT note shift - XM relative note). `midi_import::parse_any` routes modules there (SF2 goes to `%TEMP%/lingstation-tracker`; project save copies soundfonts into the project). `ImportedTrack.sf2` makes `daw_midi_import` use it unless another sound is chosen.
* `bundle.rs`: written by the render thread when `daw_render_start` gets a 9th arg (bundle); stems go to `<name> bundle/audio`, then MIDI (range start = time 0, loops unrolled, tempo track), instruments, `params.json`, `project.dawproject` (stored zip with project.xml/metadata.xml + the stems; written to the DAWproject 1.0 layout, checked with a zip/XML parser but not opened in Bitwig) and README. Tests: `tests/tracker.rs`, `tests/stems.rs`.

## Sampler, tracker fidelity, karaoke, sheet music

* `dsp/sampler.rs`: `Sampler` (zones, voices, `Env` volume / pan / pitch / filter envelopes with sustain + loop, TPT state-variable low-pass, per-note random variation) driven through `Instrument::command` / `info` (zones, add, add_lib, merge, set, detect, autoloop, spread, peaks, env, drop_env); config is JSON in `TrackDoc.config`, files packed to `samples/`. `load_audio` falls back to ffmpeg (WavPack ...) and decodes Ogg-in-WAV. Builtins in `api/smp_api.rs` (`daw_smp_*`, also the drum-sample search with synonyms: `daw_smp_roots_set`, `daw_smp_pick`).
* `tracker.rs`: `play` is a tick-level channel simulation (arp / slides / vibrato / tremolo / retrig / pattern loop & delay / Zxx); channels with continuous effects become their own `PTrack` with bend / vol / cutoff / resonance lanes; notes closed by a new note or a cut carry `reso = 0` (the Sampler treats that as a cut with no tail). Calibrate against libopenmpt: `ffmpeg -i x.mptm ref.wav` renders ground truth (ffmpeg has libopenmpt), `tests/tracker.rs::render_real_files_for_comparison` writes ours to %TEMP%.
* `karaoke.rs` + `karaoke_player.html` (syllables from `lyricfit::fit_text`, ASS / SRT / LRC / HTML), `sheet.rs`, `bundle.rs`; builtins `daw_kar_*`, `daw_sheet_*`, `daw_trk_*` (tracker grid).
* Launching: `run.bat` uses `..\ling	arget
elease\ling.exe` and falls back to the debug build, which is old and lacks the newer builtins: never leave the release exe missing.

← Back to index