diff --git a/releases/84_CosmikC1zzl3/C1ZZL3.cpp b/releases/84_CosmikC1zzl3/C1ZZL3.cpp index aaaba585..4d9c617f 100644 --- a/releases/84_CosmikC1zzl3/C1ZZL3.cpp +++ b/releases/84_CosmikC1zzl3/C1ZZL3.cpp @@ -229,15 +229,22 @@ class C1ZZL3 : public ComputerCard updateTuringClockState(false); - bool pulse2Trigger = PulseIn2RisingEdge(); + bool pulse2GateHigh = PulseIn2(); + bool pulse2Trigger = pulse2GateHigh && !pulse2GateWasHigh; + pulse2GateWasHigh = pulse2GateHigh; + if (pulse2Trigger) { if (envelopePreset != (uint8_t)EnvelopePreset::Off) { syncOscillators(); - triggerEnvelope(); + triggerEnvelope(true, true); } } + else if (pulse2EnvelopeHolding && !pulse2GateHigh) + { + requestEnvelopeRelease(); + } outputSynthVoice(freq, pd, wave, ring, noiseAmt); @@ -322,14 +329,22 @@ class C1ZZL3 : public ComputerCard static constexpr uint8_t EnvelopePresetCount = 9; static constexpr uint8_t CustomEnvelopePreset = EnvelopePresetCount; static constexpr uint8_t CustomEnvelopeSlotCount = 8; + static constexpr uint8_t EnvelopeLoopStartStage = 2; + static constexpr uint8_t EnvelopeLoopEndStage = 5; + static constexpr uint16_t EnvelopeLoopLevelThreshold = 128; + static constexpr uint32_t EnvelopeLoopMinStageSamples = 960u; static constexpr uint32_t MinWebMidiEnvelopeSamples = 960u; static constexpr uint32_t MaxWebMidiStageSamples = 192000u; static constexpr uint32_t StartupSelectDelaySamples = 12000u; static constexpr uint32_t StartupSelectWindowSamples = 24000u; static constexpr uint32_t SaveMagic = 0x43315A33u; // C1Z3 static constexpr uint16_t SaveVersion = 3; - static constexpr int32_t OutputLowpassAlphaQ12 = 2458; // 7 kHz at 48 kHz. + static constexpr int32_t OutputLowpassAlphaQ12 = 2008; // ~5.2 kHz at 48 kHz. static constexpr int32_t OutputHighpassAlphaQ12 = 4075; // 40 Hz at 48 kHz. + static constexpr int32_t PdCompensationFloorQ12 = 3000; // Keep high-PD tones present but less inflated. + static constexpr int32_t HighPitchSofteningStart = 28000000; + static constexpr int32_t HighPitchSofteningRange = 42000000; + static constexpr int32_t HighPitchSofteningMaxQ12 = 1200; static constexpr uint32_t SaveFlashOffset = (PICO_FLASH_SIZE_BYTES - FLASH_SECTOR_SIZE) & ~(FLASH_SECTOR_SIZE - 1u); @@ -482,6 +497,7 @@ class C1ZZL3 : public ComputerCard osc1 = mix(osc1, ringSig, ringMix); int32_t ampScale = envelopeAmpScale(envelopeLevel); + ampScale = (ampScale * pdCompensationScale(pd)) >> 12; ampScale = (ampScale * updateSyncFade()) >> 12; osc1 = (osc1 * ampScale) >> 12; osc2 = (osc2 * ampScale) >> 12; @@ -537,6 +553,7 @@ class C1ZZL3 : public ComputerCard int32_t sine = getSine(renderPhase); int32_t target = morphWave(renderPhase, wave); + target = softenHighPitchTarget(sine, target, freq, pdCurve); return mix(sine, target, pdCurve); } @@ -558,7 +575,7 @@ class C1ZZL3 : public ComputerCard phase += (uint32_t)phaseJitter; } - void triggerEnvelope() + void triggerEnvelope(bool held = false, bool heldByPulse = false) { if (envelopePreset == (uint8_t)EnvelopePreset::Off) return; @@ -577,6 +594,9 @@ class C1ZZL3 : public ComputerCard pdEnvelopeStartLevel = pdStartLevel; pdEnvelopeLevel = pdStartLevel; envelopeActive = true; + envelopeHeld = held; + envelopeReleaseRequested = !held; + pulse2EnvelopeHolding = heldByPulse; } void triggerTuringEnvelope() @@ -591,24 +611,34 @@ class C1ZZL3 : public ComputerCard return 0; const EnvelopeProgram& program = envelopeProgram(); + bool loopEnabled = envelopeLoopEnabled(program); bool ampDone = updateEnvelopeRunner( program.amp, ampEnvelopeStage, ampEnvelopeSample, ampEnvelopeLevel, - ampEnvelopeStartLevel); + ampEnvelopeStartLevel, + envelopeHeld, + envelopeReleaseRequested, + loopEnabled); bool pdDone = updateEnvelopeRunner( program.pd, pdEnvelopeStage, pdEnvelopeSample, pdEnvelopeLevel, - pdEnvelopeStartLevel); + pdEnvelopeStartLevel, + envelopeHeld, + envelopeReleaseRequested, + loopEnabled); if (ampDone && pdDone) { envelopeActive = false; + envelopeHeld = false; + envelopeReleaseRequested = false; + pulse2EnvelopeHolding = false; if (midiNoteReleased) midiNoteActive = false; } @@ -670,7 +700,10 @@ class C1ZZL3 : public ComputerCard if (envelopePreset == (uint8_t)EnvelopePreset::Off || !envelopeActive) midiNoteActive = false; else + { midiNoteReleased = true; + requestEnvelopeRelease(); + } } return; } @@ -736,7 +769,7 @@ class C1ZZL3 : public ComputerCard if (envelopePreset != (uint8_t)EnvelopePreset::Off) { syncOscillators(); - triggerEnvelope(); + triggerEnvelope(true); } } @@ -1096,11 +1129,11 @@ class C1ZZL3 : public ComputerCard }}; static const EnvelopeProgram brass = {{ - {4095, 4800}, {3400, 30000}, {0, 18000}, {0, 1}, - {0, 1}, {0, 1}, {0, 1}, {0, 1} + {4095, 4800}, {3400, 30000}, {3200, 18000}, {3000, 18000}, + {3300, 18000}, {3100, 18000}, {1600, 18000}, {0, 24000} }, { - {1792, 4800}, {900, 30000}, {0, 18000}, {0, 1}, - {0, 1}, {0, 1}, {0, 1}, {0, 1} + {1792, 4800}, {900, 30000}, {1200, 18000}, {1000, 18000}, + {1300, 18000}, {900, 18000}, {500, 18000}, {0, 24000} }}; static const EnvelopeProgram strings = {{ @@ -1163,11 +1196,17 @@ class C1ZZL3 : public ComputerCard uint8_t& stage, uint32_t& sample, int32_t& level, - int32_t& startLevel) + int32_t& startLevel, + bool held, + bool releaseRequested, + bool loopEnabled) { if (stage >= 8) return true; + if (loopEnabled && stage > EnvelopeLoopEndStage && held && !releaseRequested) + stage = EnvelopeLoopStartStage; + uint32_t time = stages[stage].time; if (time == 0) time = 1; @@ -1182,11 +1221,38 @@ class C1ZZL3 : public ComputerCard stage++; sample = 0; startLevel = level; + + if (loopEnabled && stage > EnvelopeLoopEndStage && held && !releaseRequested) + stage = EnvelopeLoopStartStage; } return stage >= 8; } + bool envelopeLoopEnabled(const EnvelopeProgram& program) + { + return laneHasLoopBody(program.amp) || laneHasLoopBody(program.pd); + } + + bool laneHasLoopBody(const EnvelopeStage* stages) + { + for (uint32_t i = EnvelopeLoopStartStage; i <= EnvelopeLoopEndStage; ++i) + { + if (stages[i].level >= EnvelopeLoopLevelThreshold && + stages[i].time >= EnvelopeLoopMinStageSamples) + return true; + } + + return false; + } + + void requestEnvelopeRelease() + { + envelopeHeld = false; + envelopeReleaseRequested = true; + pulse2EnvelopeHolding = false; + } + inline int32_t morphWave(uint32_t phase, int32_t wave) { uint32_t scaled = ((uint32_t)wave * 7u); @@ -2112,6 +2178,33 @@ class C1ZZL3 : public ComputerCard return (ux * ux) >> 12; } + int32_t pdCompensationScale(int32_t pd) + { + int32_t pdCurve = responseCurve(pd); + int32_t reduction = (pdCurve * (4095 - PdCompensationFloorQ12)) >> 12; + return 4095 - reduction; + } + + int32_t softenHighPitchTarget( + int32_t sine, + int32_t target, + int32_t freq, + int32_t pdCurve) + { + int32_t pitchAmount = freq - HighPitchSofteningStart; + if (pitchAmount <= 0 || pdCurve <= 0) + return target; + + if (pitchAmount > HighPitchSofteningRange) + pitchAmount = HighPitchSofteningRange; + + int32_t pitchCurve = (pitchAmount << 12) / HighPitchSofteningRange; + int32_t soften = + (((pitchCurve * pdCurve) >> 12) * HighPitchSofteningMaxQ12) >> 12; + + return mix(target, sine, soften); + } + uint32_t smoothStep12(uint32_t x) { uint32_t x2 = (x * x) >> 12; @@ -2153,6 +2246,10 @@ class C1ZZL3 : public ComputerCard uint8_t ampEnvelopeStage = 8; uint8_t pdEnvelopeStage = 8; bool envelopeActive = false; + bool envelopeHeld = false; + bool envelopeReleaseRequested = true; + bool pulse2GateWasHigh = false; + bool pulse2EnvelopeHolding = false; uint8_t envelopePreset = 0; uint32_t startupSelectSamples = 0; uint32_t envelopeSelectHoldSamples = 0; diff --git a/releases/84_CosmikC1zzl3/CARD_README.md b/releases/84_CosmikC1zzl3/CARD_README.md index c25ab132..6b9c0e76 100644 --- a/releases/84_CosmikC1zzl3/CARD_README.md +++ b/releases/84_CosmikC1zzl3/CARD_README.md @@ -23,7 +23,9 @@ Put the switch in the middle. - `Audio/CV In 1` adds pitch at 1V/oct. - `CV In 1` adds phase distortion. - `CV In 2` adds wave control. -- `Pulse In 2` triggers the selected envelope and oscillator sync. +- `Pulse In 2` triggers the selected envelope, loops supported envelopes while + the gate is high, exits the loop when the gate falls, and syncs the + oscillators. The Turing CV and pulse outputs continue running in synth mode, so they can be used while playing the synth. @@ -78,7 +80,11 @@ MIDI CC controls on the selected input channel: Physical knobs use pickup after MIDI changes, so values do not jump until the knob is swept through the current setting. -## Web MIDI Editor +MIDI note-on behaves like a held gate. It triggers the selected envelope and +keeps loop-capable envelopes cycling until MIDI note-off lets the envelope +complete naturally. + +## Envelope Lab Hosted editor: @@ -94,7 +100,7 @@ choose the C1ZZL3 output, then: 3. Drag points on the graph to change both level and timing. 4. Watch the point numbers. Matching numbers mean the stages are stacked at the same spot. 5. Use the tables below the graph for exact values when you want precise edits. -6. Use the action buttons on the right when you want to send, save, read, or export. +6. Use the action buttons on the right when you want to send, save, read, or reset. Button quick reference: @@ -106,7 +112,6 @@ Button quick reference: - `Read Envelopes from Card` to load saved card envelopes into the editor. - `Read Settings from Card` to load the current card settings into the editor. - `Send Settings` to send performance settings until reset. -- `Export JSON` to download all editor presets. - `Reset Preset` to restore the selected preset to its factory value. The card can save up to eight custom envelopes. Factory presets are not diff --git a/releases/84_CosmikC1zzl3/README.md b/releases/84_CosmikC1zzl3/README.md index afa4f466..ab9a4629 100644 --- a/releases/84_CosmikC1zzl3/README.md +++ b/releases/84_CosmikC1zzl3/README.md @@ -24,24 +24,25 @@ uf2/C1ZZL3.uf2 Checksum: ```text -12c356ad75d6d25fccab5060dbb057f1e3ed86169c2429a798c66f835825ed61 +254fa4e525b8c66201a4bfe944b4cdcd2909a61caff440e383ef23d9b3e1890a ``` -This is hardware-tested production release 1.1, promoted on 2026-07-06. +This is hardware-tested production release 1.3, promoted on 2026-07-17. -Release 1.1 works with Envelope Lab and C1ZZL3 Import Lab and includes Web +Release 1.3 works with Envelope Lab and C1ZZL3 Import Lab and includes Web MIDI PD, detune, eight waveform families, card-to-editor envelope readback, -and browser CZ patch import handoff. +browser CZ patch import handoff, gate-held envelope looping with natural +completion on gate/note release, corrected CZ DCW-to-PD and DCA-to-amplitude +import mapping, and the audio smoothing pass for high-PD tones. ## Current Stable Feature Set - Phase-distortion synth voice. - Factory envelopes plus eight protected custom envelope slots. - Web MIDI envelope editor. -- Hosted CZ patch import workflow. - USB MIDI device mode for DAW/browser use. - USB MIDI host mode for class-compliant controllers. -- MIDI notes with envelope triggering. +- MIDI notes with gate-held envelope sustain/release. - MIDI CC control with knob pickup handoff. - Turing machine audio, CV, pulse, and optional MIDI note output. - Turing CV and pulse outputs continue running in synth mode. @@ -57,7 +58,7 @@ Switch middle: synth mode. - Y: waveform - `CV In 1`: phase-distortion modulation - `CV In 2`: waveform modulation -- `Pulse In 2`: envelope trigger and oscillator sync +- `Pulse In 2`: gate-held envelope trigger/release and oscillator sync Switch down from middle: performance edit and save. @@ -120,10 +121,20 @@ Hosted import lab: https://tomwhitwell.github.io/Workshop_Computer/programs/84-cosmikc1zzl3/web/import/index.html ``` -The Import Lab is included at `web/import/index.html`. It decodes Casio CZ -`.syx` patches into C1ZZL3 drafts, then hands the result to Envelope Lab for -final editing and sending. The navigation buttons at the top of both pages -switch between the two labs. +Local import lab from this release folder: + +```sh +python3 -m http.server 5174 --directory web/import +``` + +Open: + +```text +http://localhost:5174 +``` + +Use this page to decode Casio CZ `.syx` patches into C1ZZL3 drafts, then open +the result in Envelope Lab for final editing and sending. Current Import Lab features: @@ -131,6 +142,11 @@ Current Import Lab features: - Larger `C1ZZL3 Import Lab` header and a clearer guided import workflow. - Drag-and-drop or file-picker import for Casio CZ `.syx` files. - Browser-side validation, patch summary, decoded data, and draft mapping. +- CZ frame awareness for common patch-send SysEx files, including command, + location, channel, selected data offset, and payload candidates. +- Correct first-pass envelope assignment: CZ DCW maps to C1ZZL3 phase + distortion, CZ DCA maps to C1ZZL3 amplitude, and CZ pitch is decoded but not + assigned. - Draft handoff into Envelope Lab in a new tab for final editing and card send. - Separate import page so CZ translation and envelope editing stay distinct. @@ -169,27 +185,23 @@ Use `Save Envelope` to retain an envelope in flash. To make the current performance settings the startup baseline, move the hardware switch from middle to down and hold it until the card confirms the save. -The card can save up to eight custom envelopes. The browser can retain -additional local drafts. Factory presets are not overwritten. +The card can save up to eight custom envelopes. The browser can retain additional +local drafts. Factory presets are not overwritten. Custom presets are labelled `Local only`, `Saved - slot N`, or `Changed - slot N`. -Envelope readback confirms which custom slots are occupied and verifies saves -and deletions when supported by the firmware. +Envelope readback confirms which custom slots are occupied and verifies saves and +deletions when supported by the firmware. -`Play` loops a browser preview of the envelope. -`Stop` stops the browser preview only. It does not send a stop command to the -hardware. -`Bounce` is the reset preset because it shows the envelope shape clearly. +Envelope behaviour: -## Mobile Web MIDI Notes - -- Mobile browser support is less consistent than desktop support. -- If `MIDI` connects but no output appears, open `Developer tools` in the editor - and check `MIDI Ports Seen By Browser`. -- Some mobile browsers report MIDI access but expose ports in a non-standard - way. The editor tries several detection paths, but browser/device support can - still vary. -- If ports still do not appear, reconnect the device, reload the page, and try - another USB adapter, hub, or browser. +- Pulse In 2 and MIDI note-on trigger the selected envelope and sync the + oscillators. +- While the gate or MIDI note is held, loop-capable envelopes cycle their middle + stages. +- A short trigger runs the envelope through to completion. +- Pulse In 2 gate-off or MIDI note-off exits the loop and lets the envelope + complete naturally from its current point. +- Turing-triggered envelopes continue to run through without waiting for a gate + release. ## Build @@ -207,8 +219,8 @@ build/C1ZZL3.uf2 The production source build currently reports: ```text -FLASH: 138160 B -RAM: 146500 B +FLASH: 139072 B +RAM: 147412 B ``` ## Stability Notes @@ -225,3 +237,4 @@ Possible future optimisation notes are kept in: ```text FUTURE_NOTES.md ``` + diff --git a/releases/84_CosmikC1zzl3/info.yaml b/releases/84_CosmikC1zzl3/info.yaml index 6e66be9d..0de0a0b6 100644 --- a/releases/84_CosmikC1zzl3/info.yaml +++ b/releases/84_CosmikC1zzl3/info.yaml @@ -1,16 +1,18 @@ id: 84_CosmikC1zzl3 title: Cosmik C1ZZL3 draft: false -release: 84 / 1.1 -date: 2026-06-24 +release: 84 / 1.3 +date: 2026-07-17 summary: >- - Stable phase-distortion synthesiser and Turing machine firmware with Web MIDI - envelope readback, PD, detune, eight waveform families, hosted CZ patch - import, USB MIDI device/host operation, and optional Turing MIDI output. + Stable phase-distortion synthesiser and Turing machine firmware with gate-held + looping Web MIDI envelopes, envelope readback, PD, detune, eight waveform + families, hosted CZ patch import, USB MIDI device/host operation, and optional + Turing MIDI output. short-description: >- - Stable phase-distortion synthesiser and Turing machine firmware with Web MIDI - envelope readback, PD, detune, eight waveform families, hosted CZ patch - import, USB MIDI device/host operation, and optional Turing MIDI output. + Stable phase-distortion synthesiser and Turing machine firmware with gate-held + looping Web MIDI envelopes, envelope readback, PD, detune, eight waveform + families, hosted CZ patch import, USB MIDI device/host operation, and optional + Turing MIDI output. panel: controls: main: @@ -61,8 +63,8 @@ panel: Envelope Trig description: >- - Triggers selected envelope and oscillator sync in synth mode when preset is - active + Triggers selected envelope and oscillator sync in synth mode; held gates loop + supported envelopes and gate-off lets them complete source: info.yaml outputs: audio_out_l: @@ -139,18 +141,20 @@ download_url: >- metadata: creator: Adrian Vos language: C++ (Pico SDK) - version: 1.1 + version: 1.3 status: Released license: MIT editor_url: https://tomwhitwell.github.io/Workshop_Computer/programs/84-cosmikc1zzl3/web/index.html editor_note: Configure this card in Envelope Lab; use web/import/index.html for CZ patch translation. documentation: intro: > - Phase-distortion synth card with Turing machine mode, USB MIDI, and browser - editor tools. + Phase-distortion synth card with Turing machine mode, USB MIDI, browser + editor tools, CZ Import Lab handoff, gate-held envelope looping, natural + trigger completion, and audio smoothing for high-PD tones. Switch middle is PD synth mode (Main = pitch, X = phase distortion, Y = waveform - morph). + morph). Pulse In 2 and MIDI note-on loop supported envelopes while held; + gate/note-off lets the envelope complete naturally. Hold switch down from middle for performance edit (detune, ring modulation, noise/grit) and save. diff --git a/releases/84_CosmikC1zzl3/uf2/C1ZZL3.uf2 b/releases/84_CosmikC1zzl3/uf2/C1ZZL3.uf2 index 2f94d287..dc9ab74a 100644 Binary files a/releases/84_CosmikC1zzl3/uf2/C1ZZL3.uf2 and b/releases/84_CosmikC1zzl3/uf2/C1ZZL3.uf2 differ diff --git a/releases/84_CosmikC1zzl3/uf2/SHA256SUMS.txt b/releases/84_CosmikC1zzl3/uf2/SHA256SUMS.txt index 7add5e31..996c7cf9 100644 --- a/releases/84_CosmikC1zzl3/uf2/SHA256SUMS.txt +++ b/releases/84_CosmikC1zzl3/uf2/SHA256SUMS.txt @@ -1,11 +1 @@ -12c356ad75d6d25fccab5060dbb057f1e3ed86169c2429a798c66f835825ed61 uf2/C1ZZL3.uf2 -6cb55940ab502cb38926d20f3cdd76e5f93909ebb391569d82ee385bb20fbf55 uf2/archive/production-1.0-20260706/C1ZZL3_1.0_production.uf2 -3810c1afc3f6ce6a2c596d85aaac18349367c2efc01bad3adffb4a09ff819165 uf2/archive/previous-versions-20260625/C1ZZL3_before_env_fix.uf2 -86d5a4198d1593662ec5747107f6c77797fac608eb7d7dc8a444796dffa25099 uf2/archive/current-rollback/C1ZZL3_current_rollback_20260616.uf2 -22aaf7673e8169bb6f34d0fc2450f0ecccf82857278e0c428083162e854415dd uf2/archive/previous-versions-20260616/C1ZZL3_previous_main_20260616.uf2 -22aaf7673e8169bb6f34d0fc2450f0ecccf82857278e0c428083162e854415dd uf2/archive/previous-versions-20260616/C1ZZL3_web_midi_delete_slots_production_20260615.uf2 -83cef031d0d17bb7e34408a298dc200b93fb4ef9cb0fdcfa66fa1356a13d2b4c uf2/archive/previous-versions-20260615/C1ZZL3_web_midi_production_20260613.uf2 -36789f89c9979157b09a1055b8e930c32812cb5d11c856e6a038e607b8254dd6 uf2/archive/previous-versions-20260615/C1ZZL3_web_midi_production_candidate_20260613.uf2 -36789f89c9979157b09a1055b8e930c32812cb5d11c856e6a038e607b8254dd6 uf2/archive/previous-versions-20260615/C1ZZL3_with_tap_tempo_rollback_20260615.uf2 -8e6418802f73aa7237eac8ed9ddc1c5bbeacb5360717c29e77414d3268999016 uf2/archive/previous-versions-20260615/C1ZZL3v04working.uf2 -65f1c60e4d401eac306aec8a43b43db55859b5d77f2b6ac57a9bd1fbe3086f37 uf2/archive/unstable-midi/C1ZZL3_web_midi_unstable_20260612.uf2 +254fa4e525b8c66201a4bfe944b4cdcd2909a61caff440e383ef23d9b3e1890a uf2/C1ZZL3.uf2 diff --git a/releases/84_CosmikC1zzl3/web/app.js b/releases/84_CosmikC1zzl3/web/app.js index 993bb53c..fb2ba5c6 100644 --- a/releases/84_CosmikC1zzl3/web/app.js +++ b/releases/84_CosmikC1zzl3/web/app.js @@ -36,8 +36,8 @@ const factoryPresets = [ [[4095, 240], [2600, 12000], [1200, 24000], [0, 36000], [0, 1], [0, 1], [0, 1], [0, 1]], [[2048, 240], [1600, 6000], [700, 24000], [0, 42000], [0, 1], [0, 1], [0, 1], [0, 1]]), preset("Brass", - [[4095, 4800], [3400, 30000], [0, 18000], [0, 1], [0, 1], [0, 1], [0, 1], [0, 1]], - [[1792, 4800], [900, 30000], [0, 18000], [0, 1], [0, 1], [0, 1], [0, 1], [0, 1]]), + [[4095, 4800], [3400, 30000], [3200, 18000], [3000, 18000], [3300, 18000], [3100, 18000], [1600, 18000], [0, 24000]], + [[1792, 4800], [900, 30000], [1200, 18000], [1000, 18000], [1300, 18000], [900, 18000], [500, 18000], [0, 24000]]), preset("Strings", [[2200, 24000], [3600, 24000], [3800, 48000], [3600, 48000], [3400, 48000], [3000, 48000], [1800, 48000], [0, 96000]], [[400, 12000], [900, 36000], [1200, 36000], [900, 48000], [700, 48000], [500, 48000], [400, 48000], [300, 96000]]), @@ -73,6 +73,8 @@ let envelopeReadTimer = null; let envelopeReadSupported = null; const CZ_IMPORT_HANDOFF_KEY = "c1zzl3-cz-import-draft"; const HOSTED_EDITOR_URL = "https://tomwhitwell.github.io/Workshop_Computer/programs/84-cosmikc1zzl3/web/index.html"; +const HOSTED_IMPORT_LAB_URL = "https://tomwhitwell.github.io/Workshop_Computer/programs/84-cosmikc1zzl3/web/import/"; +const LOCAL_IMPORT_LAB_URL = "import/index.html"; let messageImportedDraft = null; const WAVE_FAMILIES = [ "Saw", @@ -101,6 +103,7 @@ const el = { canvas: document.querySelector("#curveCanvas"), themeToggle: document.querySelector("#themeToggle"), onlineEditorLink: document.querySelector("#onlineEditorLink"), + importLabLink: document.querySelector("#importLabLink"), ampStages: document.querySelector("#ampStages"), pdStages: document.querySelector("#pdStages"), ampView: document.querySelector("#ampView"), @@ -404,6 +407,11 @@ function renderThemeMode() { const isLocalDev = host === "localhost" || host === "127.0.0.1" || host === "::1"; el.onlineEditorLink.classList.toggle("is-active", !isLocalDev && window.location.href.startsWith(HOSTED_EDITOR_URL)); } + if (el.importLabLink) { + const host = window.location.hostname; + const isLocalDev = host === "localhost" || host === "127.0.0.1" || host === "::1"; + el.importLabLink.href = isLocalDev ? LOCAL_IMPORT_LAB_URL : HOSTED_IMPORT_LAB_URL; + } drawCurves(); } diff --git a/releases/84_CosmikC1zzl3/web/import/app.js b/releases/84_CosmikC1zzl3/web/import/app.js index 7fce8762..e82f69ed 100644 --- a/releases/84_CosmikC1zzl3/web/import/app.js +++ b/releases/84_CosmikC1zzl3/web/import/app.js @@ -23,6 +23,8 @@ let currentDraft = null; let themeMode = loadThemeMode(); const HANDOFF_KEY = "c1zzl3-cz-import-draft"; const THEME_KEY = "c1zzl3-theme-mode"; +const LOCAL_EDITOR_URL = "../index.html"; +const HOSTED_EDITOR_URL = "https://tomwhitwell.github.io/Workshop_Computer/programs/84-cosmikc1zzl3/web/index.html"; const ENVELOPE_LAB_WINDOW_NAME = "c1zzl3-envelope-lab"; const WAVE_FAMILIES = [ { label: "Saw", value: 0, hint: "lower CC23 range" }, @@ -36,6 +38,8 @@ const WAVE_FAMILIES = [ ]; const MIN_DECODED_PATCH_BYTES = 48; const MAX_DECODED_PATCH_BYTES = 512; +const CZ_SEND_PATCH_COMMAND = 0x20; +const CZ_REQUEST_PATCH_COMMAND = 0x10; function loadThemeMode() { try { @@ -63,7 +67,9 @@ function renderThemeMode() { } function getEditorUrl() { - return new URL("../index.html", window.location.href).href; + const host = window.location.hostname; + const isLocalDev = host === "localhost" || host === "127.0.0.1" || host === "::1"; + return isLocalDev ? LOCAL_EDITOR_URL : HOSTED_EDITOR_URL; } function setStatus(text, tone = "ok") { @@ -92,15 +98,124 @@ function unpackNibbles(payload) { return bytes; } -function summarizeDecodedBytes(bytes) { +function isNibblePayload(payload) { + return Array.from(payload).every((b) => b >= 0 && b <= 15); +} + +function commandName(command) { + if (command === CZ_SEND_PATCH_COMMAND) return "send patch data"; + if (command === CZ_REQUEST_PATCH_COMMAND) return "request patch data"; + return `unknown command 0x${command.toString(16).padStart(2, "0")}`; +} + +function locationName(location) { + if (location === 0x60) return "current sound / edit buffer"; + if (location >= 0x20 && location <= 0x2f) return `internal memory ${location - 0x1f}`; + if (location >= 0x00 && location <= 0x0f) return `preset memory ${location + 1}`; + if (location >= 0x40 && location <= 0x4f) return `cartridge memory ${location - 0x3f}`; + return `location 0x${location.toString(16).padStart(2, "0")}`; +} + +function decodedLengthSupported(length) { + return length >= MIN_DECODED_PATCH_BYTES && length <= MAX_DECODED_PATCH_BYTES; +} + +function analyzePayloadCandidate(frame, offset, label) { + const payload = frame.slice(offset, -1); + const nibblePayload = isNibblePayload(payload); + const evenNibbleCount = payload.length % 2 === 0; + const decodedBytes = nibblePayload ? unpackNibbles(payload) : []; + const decodedLengthOk = decodedLengthSupported(decodedBytes.length); + + return { + offset, + label, + payload, + nibblePayload, + evenNibbleCount, + decodedBytes, + decodedLengthOk, + supported: nibblePayload && evenNibbleCount && decodedLengthOk + }; +} + +function analyzeCzFrame(frame) { + const manufacturer = frame[1]; + const familyA = frame[2]; + const familyB = frame[3]; + const channelByte = frame[4]; + const command = frame[5] ?? 0; + const location = frame[6] ?? 0; + const looksCasio = manufacturer === 0x44; + const looksCz = + looksCasio && + familyA === 0x00 && + familyB === 0x00 && + channelByte >= 0x70 && + channelByte <= 0x7f; + const channel = looksCz ? (channelByte - 0x70) + 1 : null; + const candidates = []; + + if (frame.length > 9) { + candidates.push(analyzePayloadCandidate(frame, 9, "CZ-101/CZ-1000 style data after two send bytes")); + } + if (frame.length > 7) { + candidates.push(analyzePayloadCandidate(frame, 7, "short CZ send data after location byte")); + } + + const preferred = + candidates.find((candidate) => command === CZ_SEND_PATCH_COMMAND && candidate.offset === 9 && candidate.supported) || + candidates.find((candidate) => candidate.supported) || + candidates[0] || + analyzePayloadCandidate(frame, 7, "fallback data after location byte"); + + return { + manufacturer, + familyA, + familyB, + channelByte, + channel, + command, + location, + looksCasio, + looksCz, + commandSupported: command === CZ_SEND_PATCH_COMMAND, + candidates, + preferred, + headerBytes: Array.from(frame.slice(0, Math.min(frame.length, preferred.offset))) + }; +} + +function summarizeDecodedBytes(bytes, czInfo) { const header = bytes.slice(0, 16); const tail = bytes.slice(-16); - return [ + const lines = [ `Decoded bytes: ${bytes.length}`, `Head: ${toHex(header, 16)}`, `Tail: ${toHex(tail, 16)}`, `Non-zero count: ${bytes.filter((b) => b !== 0).length}`, - ].join("\n"); + ]; + + if (czInfo) { + lines.unshift( + `CZ header bytes: ${toHex(czInfo.headerBytes, czInfo.headerBytes.length)}`, + `CZ channel: ${czInfo.channel ?? "not detected"}`, + `CZ command: ${commandName(czInfo.command)}`, + `CZ location: ${locationName(czInfo.location)}`, + `Patch data offset: ${czInfo.preferred.offset} (${czInfo.preferred.label})` + ); + + lines.push( + "", + "Payload candidates:", + ...czInfo.candidates.map((candidate) => { + const status = candidate.supported ? "usable" : "not selected"; + return `- offset ${candidate.offset}: ${candidate.payload.length} payload bytes, ${candidate.decodedBytes.length} decoded bytes, ${status}`; + }) + ); + } + + return lines.join("\n"); } function clamp(value, min, max) { @@ -166,17 +281,23 @@ function buildDraftPreset(decodedBytes, patchName) { .trim() || "Imported CZ patch"; const wave = classifyWave(decodedBytes); - const amp = buildStagesFromBytes(decodedBytes, 0, 0, 140, 14000); - const pd = buildStagesFromBytes(decodedBytes, 16, 0, 120, 18000); - const detune = clamp(Math.round((decodedBytes[32] ?? 128) / 255 * 4095), 0, 4095); - const ring = clamp(Math.round((decodedBytes[33] ?? 0) / 255 * 1200), 0, 4095); - const noise = clamp(Math.round((decodedBytes[34] ?? 0) / 255 * 700), 0, 4095); + const pitchEnvelope = buildStagesFromBytes(decodedBytes, 0, 0, 120, 18000); + const dcwEnvelope = buildStagesFromBytes(decodedBytes, 16, 0, 120, 18000); + const ampEnvelope = buildStagesFromBytes(decodedBytes, 32, 0, 140, 14000); + const detune = clamp(Math.round((decodedBytes[48] ?? 128) / 255 * 4095), 0, 4095); + const ring = clamp(Math.round((decodedBytes[49] ?? 0) / 255 * 1200), 0, 4095); + const noise = clamp(Math.round((decodedBytes[50] ?? 0) / 255 * 700), 0, 4095); return { name: `${baseName} draft`, wave, - amp, - pd, + amp: ampEnvelope, + pd: dcwEnvelope, + sourceEnvelopes: { + pitch: pitchEnvelope, + dcw: dcwEnvelope, + amp: ampEnvelope + }, performance: { detune, ring, noise }, confidence: "medium" }; @@ -201,7 +322,7 @@ function renderDraft(draft) { } el.decodedPatchBox.textContent = `${draft.name} decoded and unpacked into a draft preset.`; - el.mappedDraftBox.textContent = `8-wave family ${draft.wave.label}, translated amplitude envelope, and translated phase distortion envelope are ready for review.`; + el.mappedDraftBox.textContent = `8-wave family ${draft.wave.label}, CZ DCW envelope -> C1ZZL3 phase distortion, and CZ amplitude envelope -> C1ZZL3 amplitude are ready for review. Pitch envelope is decoded but not assigned yet.`; el.confidenceBox.textContent = draft.confidence; el.supportedOutputBox.textContent = "Envelope Lab draft handoff"; el.draftNameBox.textContent = `${draft.name} (${draft.confidence} confidence)`; @@ -270,24 +391,25 @@ function decodePatch(buffer, fileName) { }; } - const manufacturer = frame[1]; - const deviceId = frame[2]; - const modelBytes = Array.from(frame.slice(3, 7)); - const payload = frame.slice(7, -1); - const nibblePayload = Array.from(payload).every((b) => b >= 0 && b <= 15); - const decodedBytes = nibblePayload ? unpackNibbles(payload) : []; - const evenNibbleCount = payload.length % 2 === 0; - const decodedLengthOk = - decodedBytes.length >= MIN_DECODED_PATCH_BYTES && - decodedBytes.length <= MAX_DECODED_PATCH_BYTES; - const looksCasio = manufacturer === 0x44; - const supportedCandidate = looksCasio && nibblePayload && evenNibbleCount && decodedLengthOk; + const czInfo = analyzeCzFrame(frame); + const manufacturer = czInfo.manufacturer; + const payload = czInfo.preferred.payload; + const nibblePayload = czInfo.preferred.nibblePayload; + const decodedBytes = czInfo.preferred.decodedBytes; + const evenNibbleCount = czInfo.preferred.evenNibbleCount; + const decodedLengthOk = czInfo.preferred.decodedLengthOk; + const supportedCandidate = + czInfo.looksCz && + czInfo.commandSupported && + czInfo.preferred.supported; const patchName = fileName.replace(/\.(syx|mid|sysex)$/i, ""); const confidence = supportedCandidate ? "medium" : "low"; const draft = supportedCandidate ? buildDraftPreset(decodedBytes, patchName) : null; const validationReasons = []; - if (!looksCasio) validationReasons.push("manufacturer was not Casio `0x44`"); + if (!czInfo.looksCasio) validationReasons.push("manufacturer was not Casio `0x44`"); + if (czInfo.looksCasio && !czInfo.looksCz) validationReasons.push("header did not match the common CZ family form `F0 44 00 00 7n`"); + if (czInfo.looksCz && !czInfo.commandSupported) validationReasons.push(`command was ${commandName(czInfo.command)}, not send patch data`); if (!nibblePayload) validationReasons.push("payload was not nibble-packed"); if (nibblePayload && !evenNibbleCount) validationReasons.push("nibble payload length was uneven"); if (nibblePayload && !decodedLengthOk) { @@ -298,17 +420,20 @@ function decodePatch(buffer, fileName) { ok: supportedCandidate, tone: supportedCandidate ? "ok" : "warn", validation: supportedCandidate - ? `Casio-style nibble-packed patch candidate found (${frame.length} bytes, device id ${deviceId}).` + ? `Casio CZ ${commandName(czInfo.command)} frame found (${frame.length} bytes, ${locationName(czInfo.location)}, data offset ${czInfo.preferred.offset}).` : `This file does not look like a supported Casio CZ single-patch draft candidate: ${validationReasons.join("; ")}.`, patchType: supportedCandidate - ? "Casio CZ-style single-patch draft candidate" + ? "Casio CZ single-patch send frame" : "Unsupported or different synth family", summary: [ `File: ${fileName}`, `Frame length: ${frame.length} bytes`, `Manufacturer: 0x${manufacturer.toString(16).padStart(2, "0")}`, - `Device ID: ${deviceId}`, - `Model bytes: ${modelBytes.map((b) => `0x${b.toString(16).padStart(2, "0")}`).join(" ")}`, + `CZ family bytes: 0x${czInfo.familyA.toString(16).padStart(2, "0")} 0x${czInfo.familyB.toString(16).padStart(2, "0")}`, + `MIDI channel byte: 0x${czInfo.channelByte.toString(16).padStart(2, "0")}${czInfo.channel ? ` (channel ${czInfo.channel})` : ""}`, + `Command: ${commandName(czInfo.command)}`, + `Location: ${locationName(czInfo.location)}`, + `Selected data offset: ${czInfo.preferred.offset}`, `Payload bytes: ${payload.length}`, `Nibble-packed payload: ${nibblePayload ? "yes" : "no"}`, `Even nibble count: ${evenNibbleCount ? "yes" : "no"}`, @@ -317,7 +442,7 @@ function decodePatch(buffer, fileName) { `Confidence: ${confidence}` ].join("\n"), decoded: nibblePayload - ? summarizeDecodedBytes(decodedBytes) + ? summarizeDecodedBytes(decodedBytes, czInfo) : [ "Payload was not nibble-packed.", `Raw frame head: ${toHex(frame, 24)}`, diff --git a/releases/84_CosmikC1zzl3/web/import/index.html b/releases/84_CosmikC1zzl3/web/import/index.html index 077b1cdf..8a5cc9b2 100644 --- a/releases/84_CosmikC1zzl3/web/import/index.html +++ b/releases/84_CosmikC1zzl3/web/import/index.html @@ -31,7 +31,7 @@