// ============================================================================ // musikkloss — firmware for micro:bit V2 // https://musikkloss.iverfinne.no // // The cube speaks the same gesture vocabulary as the website's 3D player: // // vri høgre (turn right ~90°) -> "play" // vri venstre(turn left ~90°) -> "next" (skip) // vipp fram (tip forward ~90°) -> "next" // vipp bak (tip back ~90°) -> "prev" // lite trykk (tap the cube) -> "toggle" (pause/resume) // balanser på den runde kanten -> "shuffle:on" / "shuffle:off" // // Events are sent as text lines over the standard Bluetooth UART service // (Nordic UART, 6E400001-B5A3-F393-E0A9-E50E24DCCA9E), so any phone or // computer can subscribe — the companion iOS app in this kit maps them to // Spotify. One line per event, newline-terminated. // // Flash it: makecode.microbit.org -> New project -> JavaScript view -> paste // this file -> Extensions -> add "bluetooth" (accept removing "radio") -> // Project settings -> "No Pairing Required" -> Download to the micro:bit. // // Mounting assumption (matches the enclosure in this kit): the board stands // VERTICAL behind the front window, LED matrix facing out, logo up, micro-USB // to the right. If your board sits differently, adjust the axis signs below. // // The micro:bit V2 has an accelerometer + magnetometer but NO gyroscope, so: // - tips/balance are read from the gravity vector (accelerometer), // - turns about the vertical axis (which leave gravity unchanged) are read // from the compass heading. First boot asks for the usual tilt-to-fill- // the-screen compass calibration; do it away from speakers and magnets. // ============================================================================ // ---- tuning ---------------------------------------------------------------- const G = 1024 // 1 g in accelerometer milli-g units const REST_AXIS_MIN = 700 // |axis| above this = that axis holds gravity const TURN_DEG = 55 // compass swing that commits a turn const TURN_REBASE_MS = 900 // standing still this long re-baselines heading const BALANCE_LO = 450 // roll window: gravity shared between two axes const BALANCE_HI = 900 const BALANCE_HOLD_MS = 600 // must hold the edge this long to count const TAP_DELTA = 550 // spike above 1 g that counts as a tap const EVENT_COOLDOWN_MS = 700 // ignore everything briefly after any event const LOOP_MS = 40 // ---- state ----------------------------------------------------------------- type Pose = "standing" | "forward" | "back" | "balanced" | "other" let pose: Pose = "other" let shuffle = false let connected = false let lastEventAt = 0 let headingBase = -1 // -1 = no baseline yet let standingSince = 0 let balancedSince = 0 let lastMag = G // ---- bluetooth ------------------------------------------------------------- bluetooth.startUartService() bluetooth.onBluetoothConnected(() => { connected = true basic.showIcon(IconNames.Yes) basic.pause(400) basic.clearScreen() }) bluetooth.onBluetoothDisconnected(() => { connected = false basic.showIcon(IconNames.No) }) function send(event: string) { lastEventAt = control.millis() if (connected) bluetooth.uartWriteLine(event) flash(event) } // quick LED confirmation so the cube answers even before anything is paired function flash(event: string) { if (event == "play") basic.showLeds(` . # . . . . # # . . . # # # . . # # . . . # . . .`) else if (event == "next") basic.showLeds(` # . . # . # # . # # # # # # # # # . # # # . . # .`) else if (event == "prev") basic.showLeds(` . # . . # # # . # # # # # # # # # . # # . # . . #`) else if (event == "toggle") basic.showLeds(` . # . # . . # . # . . # . # . . # . # . . # . # .`) else basic.showIcon(IconNames.Confused) // shuffle:* -> the "?" of chance basic.pause(350) basic.clearScreen() } // ---- pose classification ---------------------------------------------------- // Board standing, logo up: gravity pulls along the board's -Y. // Tip the cube forward (window ends face-down): gravity moves onto +Z. // Tip it back (window face-up): gravity moves onto -Z. // Balanced on the rounded edge: a sustained ~45° roll about Z, i.e. gravity // shared between the X and Y axes. function classify(x: number, y: number, z: number): Pose { const ax = Math.abs(x) const ay = Math.abs(y) const az = Math.abs(z) if (y < -REST_AXIS_MIN && ax < BALANCE_LO) return "standing" if (z > REST_AXIS_MIN && ay < BALANCE_LO) return "forward" if (z < -REST_AXIS_MIN && ay < BALANCE_LO) return "back" if (ax > BALANCE_LO && ax < BALANCE_HI && y < -BALANCE_LO && az < BALANCE_LO) return "balanced" return "other" } // ---- main loop --------------------------------------------------------------- basic.showIcon(IconNames.SmallDiamond) // idle mark until first connection basic.forever(() => { const now = control.millis() const x = input.acceleration(Dimension.X) const y = input.acceleration(Dimension.Y) const z = input.acceleration(Dimension.Z) const inCooldown = now - lastEventAt < EVENT_COOLDOWN_MS // --- tap: a short shock while otherwise at rest ------------------------- const mag = Math.sqrt(x * x + y * y + z * z) if (!inCooldown && pose == "standing" && Math.abs(mag - G) > TAP_DELTA && Math.abs(lastMag - G) <= TAP_DELTA) { send("toggle") } lastMag = mag const p = classify(x, y, z) // --- tips: fire on the transition out of standing ------------------------ if (!inCooldown && pose == "standing") { if (p == "forward") send("next") else if (p == "back") send("prev") } // --- balance: hold the rounded edge to toggle shuffle --------------------- if (p == "balanced") { if (balancedSince == 0) balancedSince = now else if (!inCooldown && now - balancedSince >= BALANCE_HOLD_MS) { shuffle = !shuffle send(shuffle ? "shuffle:on" : "shuffle:off") balancedSince = 0 } } else { balancedSince = 0 } // --- turns: compass heading swings while standing ------------------------- if (p == "standing") { if (standingSince == 0) standingSince = now const heading = input.compassHeading() if (headingBase < 0 && now - standingSince >= TURN_REBASE_MS) { headingBase = heading } else if (headingBase >= 0 && !inCooldown) { // signed shortest-path difference, -180..180 let d = heading - headingBase if (d > 180) d -= 360 if (d < -180) d += 360 if (d >= TURN_DEG) { // clockwise seen from above = turn right send("play") headingBase = -1 standingSince = 0 } else if (d <= -TURN_DEG) { // counter-clockwise = turn left send("next") headingBase = -1 standingSince = 0 } } } else { // heading is only meaningful upright — invalidate it while tipped headingBase = -1 standingSince = 0 } pose = p basic.pause(LOOP_MS) })